Packages

A library for validating Telegram Mini App initialization data in Elixir

Current section

Files

Jump to
telegram_miniapp_validation lib telegram_miniapp_validation.ex
Raw

lib/telegram_miniapp_validation.ex

defmodule TelegramMiniappValidation do
@moduledoc """
Provides functionality to validate Telegram Mini App initialization data.
This module implements the validation algorithm described in the Telegram Mini Apps documentation:
https://docs.telegram-mini-apps.com/platform/init-data#validating
"""
@doc """
Validates the initialization data from a Telegram Mini App.
The validation process follows these steps:
1. Parse the query string into key-value pairs
2. Extract the hash value and remove it from the pairs
3. Sort the remaining pairs alphabetically
4. Create a data check string by joining the pairs with newlines
5. Create an HMAC-SHA256 signature of the bot token using "WebAppData" as the key
6. Create an HMAC-SHA256 signature of the data check string using the result from step 5 as the key
7. Compare the calculated hash with the provided hash
## Parameters
- `init_data`: The raw initialization data string from the Telegram Mini App
- `bot_token`: The Telegram Bot token used to sign the data
- `max_age_seconds`: Optional maximum age in seconds for the auth_date parameter (default: 86400 - 24 hours)
## Returns
- `{:ok, parsed_data}`: If validation is successful, returns the parsed data as a map
- `{:error, reason}`: If validation fails, returns the reason for failure
## Examples
This example shows the structure of the returned data, but in a real scenario,
you would need to use a valid hash and bot token:
# Using 0 for max_age_seconds to skip auth_date validation in this example
iex> init_data = "query_id=AAHdF6IQAAAAAN0XohDhrOrc&user=%7B%22id%22%3A279058397%7D&auth_date=1662771648&hash=mock_hash"
iex> TelegramMiniappValidation.parse_and_extract_hash(init_data)
{:ok, {%{"auth_date" => "1662771648", "query_id" => "AAHdF6IQAAAAAN0XohDhrOrc", "user" => %{"id" => 279058397}}, "mock_hash"}}
"""
@spec validate(String.t(), String.t(), non_neg_integer()) ::
{:ok, map()} | {:error, String.t()}
def validate(init_data, bot_token, max_age_seconds \\ 86400) do
with {:ok, {data_map, hash}} <- parse_and_extract_hash(init_data),
:ok <- validate_auth_date(data_map, max_age_seconds),
true <- valid_hash?(init_data, hash, bot_token) do
{:ok, data_map}
else
{:error, reason} -> {:error, reason}
false -> {:error, "Invalid hash"}
end
end
@doc """
Parses the initialization data and extracts the hash value.
## Parameters
- `init_data`: The raw initialization data string from the Telegram Mini App
## Returns
- `{:ok, {data_map, hash}}`: If parsing is successful, returns the data map and hash
- `{:error, reason}`: If parsing fails, returns the reason for failure
"""
@spec parse_and_extract_hash(String.t()) ::
{:ok, {map(), String.t()}} | {:error, String.t()}
def parse_and_extract_hash(init_data) do
try do
# Parse the query string into key-value pairs
pairs =
init_data
|> String.split("&")
|> Enum.map(fn pair ->
case String.split(pair, "=", parts: 2) do
[key, value] -> {key, URI.decode_www_form(value)}
_ -> nil
end
end)
|> Enum.reject(&is_nil/1)
|> Enum.into(%{})
# Extract the hash value
hash = Map.get(pairs, "hash")
if is_nil(hash) do
{:error, "Hash parameter is missing"}
else
# Remove the hash from the data map
data_map = Map.delete(pairs, "hash")
# Parse the user field if it exists and is valid JSON
data_map =
if Map.has_key?(data_map, "user") do
case Jason.decode(data_map["user"]) do
{:ok, user_map} -> Map.put(data_map, "user", user_map)
_ -> data_map
end
else
data_map
end
{:ok, {data_map, hash}}
end
rescue
e -> {:error, "Failed to parse init data: #{inspect(e)}"}
end
end
@doc """
Validates the auth_date parameter to ensure it's not too old.
## Parameters
- `data_map`: The parsed data map
- `max_age_seconds`: Maximum age in seconds for the auth_date parameter
## Returns
- `:ok`: If the auth_date is valid
- `{:error, reason}`: If the auth_date is invalid or missing
"""
@spec validate_auth_date(map(), non_neg_integer()) :: :ok | {:error, String.t()}
def validate_auth_date(data_map, max_age_seconds) do
# Skip validation if max_age_seconds is 0
if max_age_seconds == 0 do
:ok
else
case Map.get(data_map, "auth_date") do
nil ->
{:error, "Auth date is missing"}
auth_date_str ->
try do
auth_date = String.to_integer(auth_date_str)
current_time = :os.system_time(:second)
if current_time - auth_date > max_age_seconds do
{:error, "Auth date is too old"}
else
:ok
end
rescue
_ -> {:error, "Invalid auth date format"}
end
end
end
end
@doc """
Checks if the provided hash is valid for the given data and bot token.
## Parameters
- `data_map`: The parsed data map
- `hash`: The hash value to validate
- `bot_token`: The Telegram Bot token used to sign the data
## Returns
- `true`: If the hash is valid
- `false`: If the hash is invalid
"""
@spec valid_hash?(map(), String.t(), String.t()) :: boolean()
def valid_hash?(init_data, hash, bot_token) do
# Parse the query string
init_data = URI.decode_query(init_data)
# Sort key-value pairs alphabetically, excluding the hash
data_to_check =
init_data
|> Map.drop(["hash"])
|> Enum.sort()
|> Enum.map(fn {key, value} -> "#{key}=#{value}" end)
|> Enum.join("\n")
# HMAC Calculation
secret =
:crypto.mac(:hmac, :sha256, "WebAppData", bot_token)
calculated_hash =
:crypto.mac(:hmac, :sha256, secret, data_to_check)
|> Base.encode16(case: :lower)
calculated_hash == hash
end
end