Skip to main content

Crate cratefield_module_telegram

Crate cratefield_module_telegram 

Source
Expand description

cratefield-module-telegram: Telegram in the harness — the secret-token webhook, account linking, and consent buttons that never move value without a passkey (issue #764).

use cratefield_module_telegram::{Telegram, TelegramEvents};

let module = Telegram::new(TelegramEvents::new().on_message(|message| {
    Box::pin(async move { Ok(()) })
}));

The module mounts at /v1/telegram with four routes:

  • POST /webhook — one verified Telegram delivery in. The X-Telegram-Bot-Api-Secret-Token header is checked against WEBHOOK_SECRET_KEY over the raw body before any JSON is read; without the secret configured, every delivery is refused with a 503. Deduplication runs through the Inbox ledger on the update id, so a redelivery is a 200 no-op.
  • POST /link-codes and DELETE /link — the signed-in app issues a one-time code (stored only as a SHA-256 hash) and can unlink again.
  • POST /actions/{action_id}/confirm — the web app’s passkey confirmation, the only thing that can finish a value-moving approval a Telegram tap armed.

The security model of the buttons is in one sentence: a tap in Telegram can deny anything, approve anything harmless, and only arm — never finish — anything that moves value. See send_action_prompt and StepUp.

§cratefield-module-telegram

Telegram in a Cratefield venture: the secret-token webhook, account linking, and consent buttons with a rule this module does not bend — a tap in Telegram can deny anything, approve anything harmless, and only arm — never finish — anything that moves value.

The module mounts at /v1/telegram and owns four tables (telegram_inbox, telegram_link_codes, telegram_links, telegram_actions), shipped as one migration.

§Routes

RouteWhoWhat
POST /webhookTelegramOne verified delivery in. Dispatched to the venture’s hooks and to the two flows below.
POST /link-codessigned-in callerIssues a one-time code (43 base64url characters) and, when TELEGRAM_BOT_USERNAME is set, a ready-to-open deep link.
DELETE /linksigned-in callerUnlinks the caller’s Telegram account. Idempotent.
POST /actions/{action_id}/confirmsigned-in ownerThe web app’s passkey confirmation for a value-moving action.

§Configuration

Every key is optional at boot — the module fails closed per request instead — but set-and-blank fails validate_config:

KeyMeans
TELEGRAM_WEBHOOK_SECRETThe X-Telegram-Bot-Api-Secret-Token shared secret. Absent: every delivery answers 503. Set: checked over the raw body before any JSON is read, 1–256 characters of A-Z a-z 0-9 _ -.
TELEGRAM_BOT_TOKENUsed to build a bot when none was injected with .bot(..). Never logged.
TELEGRAM_BOT_USERNAMEPresent: /link-codes answers a https://t.me/<username>?start=<code> deep link. Absent: the code is issued and the deep link is omitted.
TELEGRAM_ACTION_SECRETThe HMAC key the action buttons’ tokens are signed with. Absent or blank: prompts cannot be issued and every tap is refused.
TELEGRAM_APPROVAL_URLThe web app base URL a value-moving action’s confirm button points at, with ?action=<action_id> appended.

§Linking

The signed-in app calls /link-codes and shows the deep link. The code is stored only as a SHA-256 hash, works for ten minutes, and works once. /start <code> in a private chat consumes it (a conditional update, so a code pasted twice links once), refuses a Telegram user already linked to a different account — out loud, in the chat — and answers linked. on_linked fires with the subject, the Telegram user id and the chat id.

§Action buttons

send_action_prompt sends a subject’s linked chat a text with Approve and Deny buttons. Each button carries its own compact token in callback_data (Telegram caps it at 64 bytes):

a1.<base64url(action_id[16] ‖ decision[1] ‖ exp_unix[4 BE] ‖ tag[12])>

The tag is HMAC-SHA256 over the action id, the decision, the expiry and the intended Telegram user, verified in constant time — so a button forwarded to a group is dead paper, and so is a replayed one: the decision is a conditional update out of pending, judged by rows affected.

What a tap does:

  • Deny — the action is denied, on_action fires, done.
  • Approve, not value-moving — the action is approved, on_action fires with passkey_confirmed: false, done.
  • Approve, value-moving — the action only moves to awaiting_passkey, no hook fires, and the bot replies with a URL button to TELEGRAM_APPROVAL_URL?action=<action_id>.

§The passkey rule

awaiting_passkey has exactly one exit: POST /actions/{action_id}/confirm, from the web app, by the subject who owns the action, while the action is unexpired, with a configured StepUp willing to swear the request carries a fresh passkey ceremony. No StepUp injected — 403, always. Only then does the confirmation commit (single-use) and on_action fire with passkey_confirmed: true.

There is deliberately no stock StepUp implementation here. One that reads the bearer token’s amr and iat claims would be unsound: the auth service stores the login’s methods in the session and copies them into every token it mints, stamping each mint with a fresh iat — the refresh grant included. A token refreshed hours after a passkey login carries passkey and a seconds-old iat while proving nothing about the last minutes, and no access-token claim (there is no auth_time) tells the two apart.

An implementation must therefore prove what the trait’s documentation says: a passkey assertion completed for this subject within this request, or within a freshness window it names — a WebAuthn ceremony it observed, not a passkey-born session. Being signed in is not being present; a consent button that moves value requires the second one.

If the hook behind a finished confirmation fails, the route rolls the action back to awaiting_passkey — only while it still carries approved — and answers 5xx, so the caller’s retry can run the confirmation again; the same is true of a Telegram tap whose on_action hook fails, and of a /start <code> whose on_linked hook fails (the code is put back; the redelivery re-runs the linking).

§Composition

ⓘ
use std::sync::Arc;
use cratefield_module_telegram::{Telegram, TelegramEvents, StepUp};

let module = Telegram::new(
    TelegramEvents::new()
        .on_message(|message| Box::pin(async move { Ok(()) }))
        .on_linked(|linked| Box::pin(async move {
            println!("{} linked telegram user {}", linked.subject, linked.telegram_user_id);
            Ok(())
        }))
        .on_action(|decision| Box::pin(async move { Ok(()) })),
)
// .bot(Arc::new(HttpTelegramBot::new(http, clock, token)))  // or TELEGRAM_BOT_TOKEN
//     .random(rng)                                            // required: codes and ids
//     .step_up(Arc::new(MyStepUp::new(...)));                 // see `StepUp`: a fresh passkey assertion, per request

.random(..) should always be there — self_check names its absence; without it no link code or action id can be drawn.

Structs§

ActionDecision
A consent decision, reported to the venture’s on_action hook.
ActionPrompt
A prompt Telegram accepted.
ActionRequest
One consent request, as the venture files it.
LinkCodeResponse
The response of POST /link-codes.
Linked
A Telegram account has been linked to a subject — the end of a successful /start <code> in a private chat.
Telegram
The Telegram module: the webhook, account linking, and the consent buttons.
TelegramEvents
The venture’s hooks: TelegramEvents::new().on_message(|message| ...).
VerifiedToken
What a token that opened says.

Enums§

ActionError
Why a prompt could not be issued.
Decision
Which button the token stands for.

Constants§

ACTION_SECRET_KEY
The config key holding the HMAC key the action buttons’ tokens are signed with. Absent or blank fails closed: send_action_prompt cannot issue and every tap is refused.
ACTION_TOKEN_PREFIX
The prefix every action token starts with: what tells a callback query apart from any other data a venture’s buttons might carry.
APPROVAL_URL_KEY
The config key holding the web app’s base URL — where a value-moving action’s passkey confirmation happens. Telegram is told it as a URL button with the action id appended to its query string (?action=…, joined with & when the URL already carries a query).
BOT_TOKEN_KEY
The config key holding the bot token. Read only when no bot was injected with Telegram::bot; the token is never logged, and a missing one means replies are skipped with a warning — never a crash.
BOT_USERNAME_KEY
The config key holding the bot’s @username. When set, POST /link-codes answers a ready-to-open https://t.me/<username>?start=<code> deep link alongside the code; when absent, the code is still issued and the deep link is simply omitted.
LINK_CODE_TTL
How long a link code works: ten minutes. Long enough to switch from the web app to Telegram and press send; short enough that a code pasted into a group chat hours ago is dead paper.
MAX_ACTION_TTL
How long a prompt may live at most: an hour. A consent request that outlives the conversation it started in is one nobody remembers making.
MODULE_NAME
The module’s name: mounted at /v1/telegram, config keys prefixed TELEGRAM_.
WEBHOOK_SECRET_KEY
The config key holding the webhook’s shared secret (X-Telegram-Bot-Api-Secret-Token). Absent fails closed — every delivery answers 503 rather than trusting unverified bytes; set but blank fails Module::validate_config.

Traits§

StepUp
Proves, or refuses to prove, that a request carries a fresh passkey ceremony for subject.

Functions§

action_key
The TELEGRAM_ACTION_SECRET value as key bytes, None when absent or blank — the fail-closed read every issuer and every tap goes through.
action_token
Mints the token for one button of an action prompt.
open_action_token
Opens token as if it were pressed by telegram_user_id, at Unix time now_unix. The tag check is constant-time (Mac::verify_truncated_left), and an expired token is refused here so a stale button never reaches the database.
send_action_prompt
Sends one action prompt to the subject’s linked chat: the request’s text with an Approve and a Deny button under it.

Type Aliases§

TelegramHook
A venture hook: called inline as part of handling a delivery (or a confirm request). Its error is the delivery’s 5xx — which is what makes Telegram retry, and what keeps the dedup key unclaimed.