Skip to main content

cratefield_module_telegram/
token.rs

1//! The compact signed token an action button carries in its
2//! `callback_data`, and the constant-time check that opens one.
3//!
4//! Telegram caps `callback_data` at **64 bytes**, so the Signer port's
5//! tokens — and anything else a venture might already mint — do not fit.
6//! A button instead carries a purpose-built token:
7//!
8//! ```text
9//! a1.  <base64url, no pad, of:>
10//!      action_id  [22 bytes]   the id's base64url text — the row key itself
11//!      decision   [1 byte]     1 = approve, 0 = deny
12//!      exp_unix   [4 bytes BE] when the button stops working
13//!      tag        [12 bytes]   HMAC-SHA256, leftmost 12
14//! ```
15//!
16//! The tag is over `"telegram.action"`, the action id text, the decision,
17//! the expiry and the **intended Telegram user**, so a button forwarded
18//! to a group is dead paper: anyone else pressing it fails the check
19//! before the database is asked anything. The whole token is 39 bytes
20//! with the prefix — well under the cap.
21
22use base64::Engine as _;
23use base64::engine::general_purpose::URL_SAFE_NO_PAD as BASE64URL;
24use hmac::{Hmac, KeyInit, Mac};
25use serde::{Deserialize, Serialize};
26use sha2::Sha256;
27
28/// The prefix every action token starts with: what tells a callback query
29/// apart from any other `data` a venture's buttons might carry.
30pub const ACTION_TOKEN_PREFIX: &str = "a1.";
31
32/// Base64url, no padding — the encoding of an action id and of a token's
33/// payload. No pad keeps a 16-byte id at 22 characters and a whole token
34/// well under Telegram's 64-byte `callback_data` cap.
35pub(crate) fn b64url(bytes: &[u8]) -> String {
36    BASE64URL.encode(bytes)
37}
38
39/// How many bytes of the HMAC tag ride in the token — 96 bits, plenty for
40/// a token that expires within the hour.
41const TAG_LEN: usize = 12;
42
43/// One byte naming the decision, as the token carries it.
44const APPROVE_BYTE: u8 = 1;
45/// One byte naming the decision, as the token carries it.
46const DENY_BYTE: u8 = 0;
47
48/// The domain-separation prefix of every tag, so bytes minted for another
49/// purpose in this deployment never verify here.
50const TAG_CONTEXT: &[u8] = b"telegram.action";
51
52/// Which button the token stands for.
53#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
54pub enum Decision {
55    /// The Approve button.
56    Approve,
57    /// The Deny button.
58    Deny,
59}
60
61impl Decision {
62    /// The one byte the token carries.
63    fn byte(self) -> u8 {
64        match self {
65            Self::Approve => APPROVE_BYTE,
66            Self::Deny => DENY_BYTE,
67        }
68    }
69
70    /// The decision a token byte names; `None` for any other value.
71    fn from_byte(byte: u8) -> Option<Self> {
72        match byte {
73            APPROVE_BYTE => Some(Self::Approve),
74            DENY_BYTE => Some(Self::Deny),
75            _ => None,
76        }
77    }
78}
79
80/// What a token that opened says.
81#[derive(Debug, Clone, PartialEq, Eq)]
82pub struct VerifiedToken {
83    /// The action row the button belongs to.
84    pub action_id: String,
85    /// Which button was pressed.
86    pub decision: Decision,
87    /// When the button stopped working, as Unix seconds.
88    pub exp_unix: u32,
89}
90
91/// Mints the token for one button of an action prompt.
92///
93/// `action_id` is the base64url text the `telegram_actions` row is keyed
94/// by; `telegram_user_id` is the person the button is being sent to —
95/// the check binds it, so a forwarded button pressed by anyone else is
96/// refused.
97///
98/// # Panics
99///
100/// Never: HMAC-SHA256 accepts keys of any length, so the key setup
101/// cannot fail — the `expect` guards that invariant, nothing else.
102#[must_use]
103pub fn action_token(
104    key: &[u8],
105    action_id: &str,
106    decision: Decision,
107    exp_unix: u32,
108    telegram_user_id: i64,
109) -> String {
110    let mut mac =
111        <Hmac<Sha256> as KeyInit>::new_from_slice(key).expect("HMAC accepts any key length");
112    mac.update(TAG_CONTEXT);
113    mac.update(action_id.as_bytes());
114    mac.update(&[decision.byte()]);
115    mac.update(&exp_unix.to_be_bytes());
116    mac.update(&telegram_user_id.to_be_bytes());
117    let tag = &mac.finalize().into_bytes()[..TAG_LEN];
118
119    let mut payload = Vec::with_capacity(16 + 1 + 4 + TAG_LEN);
120    payload.extend_from_slice(action_id.as_bytes());
121    payload.push(decision.byte());
122    payload.extend_from_slice(&exp_unix.to_be_bytes());
123    payload.extend_from_slice(tag);
124    format!("{ACTION_TOKEN_PREFIX}{}", BASE64URL.encode(payload))
125}
126
127/// Opens `token` as if it were pressed by `telegram_user_id`, at Unix
128/// time `now_unix`. The tag check is constant-time (`Mac::verify_truncated_left`),
129/// and an expired token is refused here so a stale button never reaches
130/// the database.
131///
132/// The action id is returned but **not** looked up: whether the row
133/// exists, is still pending and belongs to this Telegram user is the
134/// caller's half of the check.
135///
136/// # Panics
137///
138/// Never: HMAC-SHA256 accepts keys of any length, so the key setup
139/// cannot fail — the `expect` guards that invariant, nothing else.
140#[must_use]
141pub fn open_action_token(
142    key: &[u8],
143    token: &str,
144    telegram_user_id: i64,
145    now_unix: u32,
146) -> Option<VerifiedToken> {
147    // action_id (22 for the canonical id) | decision (1) | exp (4) | tag (12).
148    // The id is variable-length text; the rest of the frame is fixed, so
149    // its length falls out of the payload's.
150    const FRAME: usize = 1 + 4 + TAG_LEN;
151
152    let payload = BASE64URL
153        .decode(token.strip_prefix(ACTION_TOKEN_PREFIX)?)
154        .ok()?;
155    if payload.len() < FRAME {
156        return None;
157    }
158    let split = payload.len() - TAG_LEN;
159    let (body, tag) = payload.split_at(split);
160    let id_len = body.len().checked_sub(FRAME - TAG_LEN)?;
161    let action_id = std::str::from_utf8(&body[..id_len]).ok()?;
162    let decision = Decision::from_byte(body[id_len])?;
163    let exp_unix = u32::from_be_bytes(body[id_len + 1..id_len + 5].try_into().ok()?);
164    if now_unix >= exp_unix {
165        return None;
166    }
167
168    let mut mac =
169        <Hmac<Sha256> as KeyInit>::new_from_slice(key).expect("HMAC accepts any key length");
170    mac.update(TAG_CONTEXT);
171    mac.update(action_id.as_bytes());
172    mac.update(&[decision.byte()]);
173    mac.update(&exp_unix.to_be_bytes());
174    mac.update(&telegram_user_id.to_be_bytes());
175    mac.verify_truncated_left(tag).ok()?;
176
177    Some(VerifiedToken {
178        action_id: action_id.to_owned(),
179        decision,
180        exp_unix,
181    })
182}
183
184/// The `TELEGRAM_ACTION_SECRET` value as key bytes, `None` when absent or
185/// blank — the fail-closed read every issuer and every tap goes through.
186#[must_use]
187pub fn action_key(config: &dyn cratefield_core::Config) -> Option<Vec<u8>> {
188    config
189        .get(crate::ACTION_SECRET_KEY)
190        .filter(|secret| !secret.trim().is_empty())
191        .map(String::into_bytes)
192}
193
194#[cfg(test)]
195mod tests {
196    use super::*;
197
198    const KEY: &[u8] = b"test-action-secret";
199    const ACTION: &str = "aGVsbG8td29ybGQtYWhlYWRsb3c"; // 16 bytes base64url
200    const USER: i64 = 777;
201    const EXP: u32 = 1_800_100_000;
202    const NOW: u32 = 1_800_000_000;
203
204    #[test]
205    fn a_minted_token_opens_for_its_owner() {
206        let token = action_token(KEY, ACTION, Decision::Approve, EXP, USER);
207        assert!(token.starts_with(ACTION_TOKEN_PREFIX));
208        assert!(token.len() <= 64, "Telegram's callback_data cap: {token}");
209
210        let opened = open_action_token(KEY, &token, USER, NOW).expect("the owner's tap verifies");
211        assert_eq!(opened.action_id, ACTION);
212        assert_eq!(opened.decision, Decision::Approve);
213        assert_eq!(opened.exp_unix, EXP);
214    }
215
216    #[test]
217    fn the_other_button_is_a_different_token() {
218        let approve = action_token(KEY, ACTION, Decision::Approve, EXP, USER);
219        let deny = action_token(KEY, ACTION, Decision::Deny, EXP, USER);
220        assert_ne!(approve, deny);
221        assert_eq!(
222            open_action_token(KEY, &deny, USER, NOW)
223                .expect("deny verifies")
224                .decision,
225            Decision::Deny
226        );
227        // An approve token's bytes do not read as a deny.
228        assert!(
229            open_action_token(KEY, &approve, USER, NOW)
230                .is_some_and(|opened| opened.decision == Decision::Approve)
231        );
232    }
233
234    #[test]
235    fn anyone_else_s_forward_is_refused() {
236        let token = action_token(KEY, ACTION, Decision::Approve, EXP, USER);
237        assert!(
238            open_action_token(KEY, &token, 888, NOW).is_none(),
239            "a tap by another Telegram user fails the tag"
240        );
241    }
242
243    #[test]
244    fn an_expired_or_tampered_token_is_refused() {
245        let token = action_token(KEY, ACTION, Decision::Approve, EXP, USER);
246        assert!(
247            open_action_token(KEY, &token, USER, EXP).is_none(),
248            "at expiry"
249        );
250        assert!(
251            open_action_token(KEY, &token, USER, EXP - 1).is_some(),
252            "one second before still works"
253        );
254
255        let mut bytes = token.into_bytes();
256        let last = bytes.len() - 1;
257        bytes[last] = bytes[last].wrapping_add(1);
258        let tampered = String::from_utf8(bytes).expect("still ascii");
259        assert!(open_action_token(KEY, &tampered, USER, NOW).is_none());
260
261        assert!(open_action_token(KEY, "b1.not-a-token", USER, NOW).is_none());
262        assert!(open_action_token(KEY, &format!("{ACTION_TOKEN_PREFIX}!!!!"), USER, NOW).is_none());
263        assert!(
264            open_action_token(
265                b"other-key",
266                &action_token(KEY, ACTION, Decision::Approve, EXP, USER),
267                USER,
268                NOW
269            )
270            .is_none()
271        );
272    }
273}