defmodule Totpex do @moduledoc """ Generate a Time-Based One-Time Password used from 2 factor authentication. Official specification: https://tools.ietf.org/html/rfc6238 To accomodate Google Authenticator secrets are accepted with or without padding. """ defp decode32(secret) do case Base.decode32(secret) do :error -> Base.decode32!(secret, padding: false) {:ok, decoded} -> decoded end end defp generate_hmac(secret, period, time) do # Clean unwanted character from the secret and decode it using Base32 "encoding" key = secret |> String.replace(" ", "") |> String.upcase() |> decode32() time = time || DateTime.utc_now() |> DateTime.to_unix() # Generate the moving mactor moving_factor = time |> Integer.floor_div(period) |> Integer.to_string(16) |> String.pad_leading(16, "0") |> String.upcase() |> Base.decode16!() # Generate SHA-1 :crypto.hmac(:sha, key, moving_factor) end defp hmac_dynamic_truncation(hmac) do # Get the offset from last 4-bits <<_::19-binary, _::4, offset::4>> = hmac # Get the 4-bytes starting from the offset <<_::size(offset)-binary, p::4-binary, _::binary>> = hmac # Return the last 31-bits <<_::1, truncation::31>> = p truncation end defp generate_hotp(truncated_hmac) do truncated_hmac |> rem(1_000_000) |> Integer.to_string() end @doc """ Generate Time-Based One-Time Password. The default period used to calculate the moving factor is 30s The secret should be valid `Base32` encoded, with or without padding. ## Examples iex> generate_totp(Base.encode32("My secret")) "79727" """ def generate_totp(secret, period \\ 30, time \\ nil) do secret |> generate_hmac(period, time) |> hmac_dynamic_truncation |> generate_hotp end @doc """ Validate Time-Based One-Time Password. Accepts the following options: * `:period` - moving factor in integer seconds (integer, default 30) * `:grace_periods` - number of periods offset from current time to accept as valid tokens (integer, default 0) """ def validate_totp(secret, totp, opts \\ []) do period = Keyword.get(opts, :period, 30) grace_periods = Keyword.get(opts, :grace_periods, 0) now = DateTime.utc_now() |> DateTime.to_unix() result = period |> generate_time_offsets(grace_periods) |> Stream.map(&Kernel.+(now, &1)) |> Stream.map(&generate_totp(secret, period, &1)) |> Enum.find(&(&1 == totp)) result == totp end def generate_time_offsets(_period, 0), do: [0] def generate_time_offsets(period, num) when is_integer(num) and num > 0 do [0 | Enum.flat_map(1..num, fn i -> [-period * i, period * i] end)] end end