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