cratefield_module_telegram/
token.rs1use 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
28pub const ACTION_TOKEN_PREFIX: &str = "a1.";
31
32pub(crate) fn b64url(bytes: &[u8]) -> String {
36 BASE64URL.encode(bytes)
37}
38
39const TAG_LEN: usize = 12;
42
43const APPROVE_BYTE: u8 = 1;
45const DENY_BYTE: u8 = 0;
47
48const TAG_CONTEXT: &[u8] = b"telegram.action";
51
52#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
54pub enum Decision {
55 Approve,
57 Deny,
59}
60
61impl Decision {
62 fn byte(self) -> u8 {
64 match self {
65 Self::Approve => APPROVE_BYTE,
66 Self::Deny => DENY_BYTE,
67 }
68 }
69
70 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#[derive(Debug, Clone, PartialEq, Eq)]
82pub struct VerifiedToken {
83 pub action_id: String,
85 pub decision: Decision,
87 pub exp_unix: u32,
89}
90
91#[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#[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 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#[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"; 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 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}