use base64::Engine as _;
use base64::engine::general_purpose::URL_SAFE_NO_PAD as BASE64URL;
use hmac::{Hmac, KeyInit, Mac};
use serde::{Deserialize, Serialize};
use sha2::Sha256;
pub const ACTION_TOKEN_PREFIX: &str = "a1.";
pub(crate) fn b64url(bytes: &[u8]) -> String {
BASE64URL.encode(bytes)
}
const TAG_LEN: usize = 12;
const APPROVE_BYTE: u8 = 1;
const DENY_BYTE: u8 = 0;
const TAG_CONTEXT: &[u8] = b"telegram.action";
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum Decision {
Approve,
Deny,
}
impl Decision {
fn byte(self) -> u8 {
match self {
Self::Approve => APPROVE_BYTE,
Self::Deny => DENY_BYTE,
}
}
fn from_byte(byte: u8) -> Option<Self> {
match byte {
APPROVE_BYTE => Some(Self::Approve),
DENY_BYTE => Some(Self::Deny),
_ => None,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct VerifiedToken {
pub action_id: String,
pub decision: Decision,
pub exp_unix: u32,
}
#[must_use]
pub fn action_token(
key: &[u8],
action_id: &str,
decision: Decision,
exp_unix: u32,
telegram_user_id: i64,
) -> String {
let mut mac =
<Hmac<Sha256> as KeyInit>::new_from_slice(key).expect("HMAC accepts any key length");
mac.update(TAG_CONTEXT);
mac.update(action_id.as_bytes());
mac.update(&[decision.byte()]);
mac.update(&exp_unix.to_be_bytes());
mac.update(&telegram_user_id.to_be_bytes());
let tag = &mac.finalize().into_bytes()[..TAG_LEN];
let mut payload = Vec::with_capacity(16 + 1 + 4 + TAG_LEN);
payload.extend_from_slice(action_id.as_bytes());
payload.push(decision.byte());
payload.extend_from_slice(&exp_unix.to_be_bytes());
payload.extend_from_slice(tag);
format!("{ACTION_TOKEN_PREFIX}{}", BASE64URL.encode(payload))
}
#[must_use]
pub fn open_action_token(
key: &[u8],
token: &str,
telegram_user_id: i64,
now_unix: u32,
) -> Option<VerifiedToken> {
const FRAME: usize = 1 + 4 + TAG_LEN;
let payload = BASE64URL
.decode(token.strip_prefix(ACTION_TOKEN_PREFIX)?)
.ok()?;
if payload.len() < FRAME {
return None;
}
let split = payload.len() - TAG_LEN;
let (body, tag) = payload.split_at(split);
let id_len = body.len().checked_sub(FRAME - TAG_LEN)?;
let action_id = std::str::from_utf8(&body[..id_len]).ok()?;
let decision = Decision::from_byte(body[id_len])?;
let exp_unix = u32::from_be_bytes(body[id_len + 1..id_len + 5].try_into().ok()?);
if now_unix >= exp_unix {
return None;
}
let mut mac =
<Hmac<Sha256> as KeyInit>::new_from_slice(key).expect("HMAC accepts any key length");
mac.update(TAG_CONTEXT);
mac.update(action_id.as_bytes());
mac.update(&[decision.byte()]);
mac.update(&exp_unix.to_be_bytes());
mac.update(&telegram_user_id.to_be_bytes());
mac.verify_truncated_left(tag).ok()?;
Some(VerifiedToken {
action_id: action_id.to_owned(),
decision,
exp_unix,
})
}
#[must_use]
pub fn action_key(config: &dyn cratefield_core::Config) -> Option<Vec<u8>> {
config
.get(crate::ACTION_SECRET_KEY)
.filter(|secret| !secret.trim().is_empty())
.map(String::into_bytes)
}
#[cfg(test)]
mod tests {
use super::*;
const KEY: &[u8] = b"test-action-secret";
const ACTION: &str = "aGVsbG8td29ybGQtYWhlYWRsb3c"; const USER: i64 = 777;
const EXP: u32 = 1_800_100_000;
const NOW: u32 = 1_800_000_000;
#[test]
fn a_minted_token_opens_for_its_owner() {
let token = action_token(KEY, ACTION, Decision::Approve, EXP, USER);
assert!(token.starts_with(ACTION_TOKEN_PREFIX));
assert!(token.len() <= 64, "Telegram's callback_data cap: {token}");
let opened = open_action_token(KEY, &token, USER, NOW).expect("the owner's tap verifies");
assert_eq!(opened.action_id, ACTION);
assert_eq!(opened.decision, Decision::Approve);
assert_eq!(opened.exp_unix, EXP);
}
#[test]
fn the_other_button_is_a_different_token() {
let approve = action_token(KEY, ACTION, Decision::Approve, EXP, USER);
let deny = action_token(KEY, ACTION, Decision::Deny, EXP, USER);
assert_ne!(approve, deny);
assert_eq!(
open_action_token(KEY, &deny, USER, NOW)
.expect("deny verifies")
.decision,
Decision::Deny
);
assert!(
open_action_token(KEY, &approve, USER, NOW)
.is_some_and(|opened| opened.decision == Decision::Approve)
);
}
#[test]
fn anyone_else_s_forward_is_refused() {
let token = action_token(KEY, ACTION, Decision::Approve, EXP, USER);
assert!(
open_action_token(KEY, &token, 888, NOW).is_none(),
"a tap by another Telegram user fails the tag"
);
}
#[test]
fn an_expired_or_tampered_token_is_refused() {
let token = action_token(KEY, ACTION, Decision::Approve, EXP, USER);
assert!(
open_action_token(KEY, &token, USER, EXP).is_none(),
"at expiry"
);
assert!(
open_action_token(KEY, &token, USER, EXP - 1).is_some(),
"one second before still works"
);
let mut bytes = token.into_bytes();
let last = bytes.len() - 1;
bytes[last] = bytes[last].wrapping_add(1);
let tampered = String::from_utf8(bytes).expect("still ascii");
assert!(open_action_token(KEY, &tampered, USER, NOW).is_none());
assert!(open_action_token(KEY, "b1.not-a-token", USER, NOW).is_none());
assert!(open_action_token(KEY, &format!("{ACTION_TOKEN_PREFIX}!!!!"), USER, NOW).is_none());
assert!(
open_action_token(
b"other-key",
&action_token(KEY, ACTION, Decision::Approve, EXP, USER),
USER,
NOW
)
.is_none()
);
}
}