Current section
Files
Jump to
Current section
Files
lib/totpex.ex
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