Skip to content
46 changes: 36 additions & 10 deletions lib/nimble_totp.ex
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,7 @@ defmodule NimbleTOTP do
"""

import Bitwise
@totp_size 6
@default_totp_size 6
@default_totp_period 30

@typedoc "Unix time in seconds, `t:DateTime.t/0` or `t:NaiveDateTime.t/0`."
Expand Down Expand Up @@ -214,6 +214,8 @@ defmodule NimbleTOTP do
*in seconds*) to be used. Default is `System.os_time(:second)`.
* `:period` - The period (in seconds) in which the code is valid. Default is `30`.
If this option is given to `verification_code/2`, it must also be given to `valid?/3`.
* `:totp_size` - The desired size of the totp. Default is 6.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Lets call it length!

If this option is given to `verification_code/2`, it must also be given to `valid?/3`.

## Examples

Expand All @@ -226,18 +228,22 @@ defmodule NimbleTOTP do
def verification_code(secret, opts \\ []) when is_binary(secret) and is_list(opts) do
time = opts |> Keyword.get_lazy(:time, fn -> System.os_time(:second) end) |> to_unix()
period = Keyword.get(opts, :period, @default_totp_period)
totp_size = Keyword.get(opts, :totp_size, @default_totp_size)

totp_size > 10 && raise ArgumentError, "totp_size cannot be above 10"
totp_size <= 0 && raise ArgumentError, "totp_size cannot be 0 or under"

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
totp_size > 10 && raise ArgumentError, "totp_size cannot be above 10"
totp_size <= 0 && raise ArgumentError, "totp_size cannot be 0 or under"
totp_size not in 6..10 && raise ArgumentError, "length must be between 6 and 10"


verification_code(secret, time, period)
verification_code(secret, time, period, totp_size)
end

@spec verification_code(binary(), integer(), pos_integer()) :: binary()
defp verification_code(secret, time, period) do
@spec verification_code(binary(), integer(), pos_integer(), integer()) :: binary()
defp verification_code(secret, time, period, totp_size) do
secret
|> hmac(time, period)
|> hmac_truncate()
|> rem(1_000_000)
|> rem(Integer.pow(10, totp_size))
|> to_string()
|> String.pad_leading(@totp_size, "0")
|> String.pad_leading(totp_size, "0")
end

defp hmac(secret, time, period) do
Expand Down Expand Up @@ -273,6 +279,9 @@ defmodule NimbleTOTP do
* `:period` - The period (in seconds) in which the code is valid. Default is `30`.
If this option is given to `verification_code/2`, it must also be given to `valid?/3`.

* `:totp_size` - The desired size of the totp. Default is 6.
If this option is given to `verification_code/2`, it must also be given to `valid?/3`.

## Preventing TOTP code reuse

The `:since` option can be used to prevent TOTP codes from being reused. When set
Expand Down Expand Up @@ -302,18 +311,35 @@ defmodule NimbleTOTP do
@spec valid?(binary(), String.t(), [option() | validate_option()]) :: boolean()
def valid?(secret, otp, opts \\ [])

def valid?(secret, <<a1, a2, a3, a4, a5, a6>>, opts) do
def valid?(secret, otp, opts) when is_binary(otp) do
time = opts |> Keyword.get(:time, System.os_time(:second)) |> to_unix()
period = Keyword.get(opts, :period, @default_totp_period)
totp_size = Keyword.get(opts, :totp_size, @default_totp_size)

totp_size > 10 && raise ArgumentError, "totp_size cannot be above 10"
totp_size <= 0 && raise ArgumentError, "totp_size cannot be 0 or under"

<<e1, e2, e3, e4, e5, e6>> = verification_code(secret, time, period)
code = verification_code(secret, time, period, totp_size)

(bxor(e1, a1) ||| bxor(e2, a2) ||| bxor(e3, a3) ||| bxor(e4, a4) ||| bxor(e5, a5) |||
bxor(e6, a6)) === 0 and not reused?(time, period, opts)
validate_digits(code, otp) == 0 and not reused?(time, period, opts)
end

def valid?(_secret, _otp, _opts), do: false

@spec validate_digits(integer(), integer()) :: :error | integer()
defp validate_digits(e, a)
when byte_size(e) !== byte_size(a), do: :error

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should be a separate clause, otherwise we are checking the byte size on every operation. I'd just move it out of this function. :)


defp validate_digits(<<e, e_rest::binary>>, <<a, a_rest::binary>>)
when byte_size(e_rest) > 0 and byte_size(a_rest) > 0 do
bxor(e, a) ||| validate_digits(e_rest, a_rest)
end

defp validate_digits(<<e, e_rest::binary>>, <<a, a_rest::binary>>)
when byte_size(e_rest) <= 0 and byte_size(a_rest) <= 0 do
bxor(e, a)
end

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
defp validate_digits(<<e, e_rest::binary>>, <<a, a_rest::binary>>)
when byte_size(e_rest) > 0 and byte_size(a_rest) > 0 do
bxor(e, a) ||| validate_digits(e_rest, a_rest)
end
defp validate_digits(<<e, e_rest::binary>>, <<a, a_rest::binary>>)
when byte_size(e_rest) <= 0 and byte_size(a_rest) <= 0 do
bxor(e, a)
end
defp validate_digits(<<e, e_rest::binary>>, <<a, a_rest::binary>>) do
bxor(e, a) ||| validate_digits(e_rest, a_rest)
end
defp validate_digits(<<>>, <<>>) do
true
end


@spec reused?(integer(), pos_integer(), [option() | validate_option()]) :: boolean()
defp reused?(time, period, opts) do
if since = Keyword.get(opts, :since) do
Expand Down
20 changes: 10 additions & 10 deletions test/nimble_totp_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -120,17 +120,17 @@ defmodule NimbleTOTPTest do
for _ <- 1..1000 do
secret = NimbleTOTP.secret()

code = NimbleTOTP.verification_code(secret, time: time)
assert code == NimbleTOTP.verification_code(secret, time: date_time)
assert code == NimbleTOTP.verification_code(secret, time: naive_date_time)
code = NimbleTOTP.verification_code(secret, time: time, totp_size: 8)
assert code == NimbleTOTP.verification_code(secret, time: date_time, totp_size: 8)
assert code == NimbleTOTP.verification_code(secret, time: naive_date_time, totp_size: 8)

assert NimbleTOTP.valid?(secret, code, time: time)
assert NimbleTOTP.valid?(secret, code, time: date_time)
assert NimbleTOTP.valid?(secret, code, time: naive_date_time)
assert NimbleTOTP.valid?(secret, code, time: time, totp_size: 8)
assert NimbleTOTP.valid?(secret, code, time: date_time, totp_size: 8)
assert NimbleTOTP.valid?(secret, code, time: naive_date_time, totp_size: 8)

refute NimbleTOTP.valid?(secret, "abcdef", time: time)
refute NimbleTOTP.valid?(secret, "abcdef", time: date_time)
refute NimbleTOTP.valid?(secret, "abcdef", time: naive_date_time)
refute NimbleTOTP.valid?(secret, "abcdefgh", time: time, totp_size: 8)
refute NimbleTOTP.valid?(secret, "abcdefgh", time: date_time, totp_size: 8)
refute NimbleTOTP.valid?(secret, "abcdefgh", time: naive_date_time, totp_size: 8)
Comment thread
josevalim marked this conversation as resolved.
Outdated
end
end

Expand Down Expand Up @@ -161,7 +161,7 @@ defmodule NimbleTOTPTest do
end
end

test "returns false if the code does not have 6 digits" do
test "returns false if the code does not have 6 digits for default totp_size" do
time = System.os_time(:second)
secret = NimbleTOTP.secret()
code = NimbleTOTP.verification_code(secret, time: time)
Expand Down