diff --git a/lib/nimble_totp.ex b/lib/nimble_totp.ex index 69a18f9..e4a1f15 100644 --- a/lib/nimble_totp.ex +++ b/lib/nimble_totp.ex @@ -55,12 +55,20 @@ defmodule NimbleTOTP do [eqrcode](https://github.com/SiliconJungles/eqrcode) to generate the QR code as **SVG**. + If you use more than 6 digits for the totp token you will need to specify + it in the otpauth uri with the `digits` option. + Example: uri = NimbleTOTP.otpauth_uri("Acme", "alice", secret) #=> "otpauth://totp/Acme:alice?secret=MFRGGZA&issuer=Acme" uri |> EQRCode.encode() |> EQRCode.svg() #=> "\\n "otpauth://totp/Acme:alice?secret=MFRGGZA&issuer=Acme" + uri |> EQRCode.encode() |> EQRCode.svg() + #=> "\\n Keyword.get_lazy(:time, fn -> System.os_time(:second) end) |> to_unix() period = Keyword.get(opts, :period, @default_totp_period) + digits = Keyword.get(opts, :digits, @default_digits) + + digits not in 6..10 && raise ArgumentError, "digits must be between 6 and 10" - verification_code(secret, time, period) + verification_code(secret, time, period, digits) 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, digits) do secret |> hmac(time, period) |> hmac_truncate() - |> rem(1_000_000) + |> rem(Integer.pow(10, digits)) |> to_string() - |> String.pad_leading(@totp_size, "0") + |> String.pad_leading(digits, "0") end defp hmac(secret, time, period) do @@ -273,6 +286,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`. + * `:digits` - The desired length of the totp. Default is 6. + If this option is given to `verification_code/2`, it must also be given to `valid?/3` and `otpauth_uri/3`/`otpauth_uri/4`. + ## Preventing TOTP code reuse The `:since` option can be used to prevent TOTP codes from being reused. When set @@ -302,18 +318,30 @@ defmodule NimbleTOTP do @spec valid?(binary(), String.t(), [option() | validate_option()]) :: boolean() def valid?(secret, otp, opts \\ []) - def valid?(secret, <>, 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) + digits = Keyword.get(opts, :digits, @default_digits) - <> = verification_code(secret, time, period) + digits not in 6..10 && raise ArgumentError, "digits must be between 6 and 10" - (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) + code = verification_code(secret, time, period, digits) + + byte_size(code) == byte_size(otp) and 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(<>, <>) do + bxor(e, a) ||| validate_digits(e_rest, a_rest) + end + + defp validate_digits(<<>>, <<>>) do + 0 + end + @spec reused?(integer(), pos_integer(), [option() | validate_option()]) :: boolean() defp reused?(time, period, opts) do if since = Keyword.get(opts, :since) do diff --git a/test/nimble_totp_test.exs b/test/nimble_totp_test.exs index 4479f1a..85e273b 100644 --- a/test/nimble_totp_test.exs +++ b/test/nimble_totp_test.exs @@ -128,9 +128,31 @@ defmodule NimbleTOTPTest do assert NimbleTOTP.valid?(secret, code, time: date_time) assert NimbleTOTP.valid?(secret, code, time: naive_date_time) - 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) + refute NimbleTOTP.valid?(secret, "abcdefgh", time: date_time) + refute NimbleTOTP.valid?(secret, "abcdefgh", time: naive_date_time) + end + end + + test "returns true if it matches the verification code with a length of 8" do + time = System.os_time(:second) + date_time = DateTime.from_unix!(time, :second) + naive_date_time = DateTime.to_naive(date_time) + + for _ <- 1..1000 do + secret = NimbleTOTP.secret() + + code = NimbleTOTP.verification_code(secret, time: time, digits: 8) + assert code == NimbleTOTP.verification_code(secret, time: date_time, digits: 8) + assert code == NimbleTOTP.verification_code(secret, time: naive_date_time, digits: 8) + + assert NimbleTOTP.valid?(secret, code, time: time, digits: 8) + assert NimbleTOTP.valid?(secret, code, time: date_time, digits: 8) + assert NimbleTOTP.valid?(secret, code, time: naive_date_time, digits: 8) + + refute NimbleTOTP.valid?(secret, "abcdefgh", time: time, digits: 8) + refute NimbleTOTP.valid?(secret, "abcdefgh", time: date_time, digits: 8) + refute NimbleTOTP.valid?(secret, "abcdefgh", time: naive_date_time, digits: 8) end end @@ -161,7 +183,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_length" do time = System.os_time(:second) secret = NimbleTOTP.secret() code = NimbleTOTP.verification_code(secret, time: time) @@ -169,6 +191,13 @@ defmodule NimbleTOTPTest do refute NimbleTOTP.valid?(secret, binary_part(code, 0, 5), time: time) refute NimbleTOTP.valid?(secret, <>, time: time) end + + test "returns false if the totp_length is under 6 or above 10" do + time = System.os_time(:second) + secret = NimbleTOTP.secret() + assert_raise ArgumentError, "length must be between 6 and 10", fn -> NimbleTOTP.verification_code(secret, time: time, digits: 5) end + assert_raise ArgumentError, "length must be between 6 and 10", fn -> NimbleTOTP.verification_code(secret, time: time, digits: 11) end + end end defp to_unix(naive_datetime),