Skip to content

Commit 586c130

Browse files
authored
Add :digits option (#39)
1 parent 9a47f0e commit 586c130

2 files changed

Lines changed: 71 additions & 14 deletions

File tree

lib/nimble_totp.ex

Lines changed: 38 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -55,12 +55,20 @@ defmodule NimbleTOTP do
5555
[eqrcode](https://github.com/SiliconJungles/eqrcode) to generate the QR
5656
code as **SVG**.
5757
58+
If you use more than 6 digits for the totp token you will need to specify
59+
it in the otpauth uri with the `digits` option.
60+
5861
Example:
5962
6063
uri = NimbleTOTP.otpauth_uri("Acme", "alice", secret)
6164
#=> "otpauth://totp/Acme:alice?secret=MFRGGZA&issuer=Acme"
6265
uri |> EQRCode.encode() |> EQRCode.svg()
6366
#=> "<?xml version=\\"1.0\\" standalone=\\"yes\\"?>\\n<svg version=\\"1.1\\" ...
67+
68+
uri = NimbleTOTP.otpauth_uri("Acme", "alice", secret, digits: 8)
69+
#=> "otpauth://totp/Acme:alice?secret=MFRGGZA&issuer=Acme"
70+
uri |> EQRCode.encode() |> EQRCode.svg()
71+
#=> "<?xml version=\\"1.0\\" standalone=\\"yes\\"?>\\n<svg version=\\"1.1\\" ...
6472
6573
### Generating a Time-Based One-Time Password
6674
@@ -128,7 +136,7 @@ defmodule NimbleTOTP do
128136
"""
129137

130138
import Bitwise
131-
@totp_size 6
139+
@default_digits 6
132140
@default_totp_period 30
133141

134142
@typedoc "Unix time in seconds, `t:DateTime.t/0` or `t:NaiveDateTime.t/0`."
@@ -214,6 +222,8 @@ defmodule NimbleTOTP do
214222
*in seconds*) to be used. Default is `System.os_time(:second)`.
215223
* `:period` - The period (in seconds) in which the code is valid. Default is `30`.
216224
If this option is given to `verification_code/2`, it must also be given to `valid?/3`.
225+
* `:digits` - The desired length of the totp. Default is 6.
226+
If this option is given to `verification_code/2`, it must also be given to `valid?/3` and `otpauth_uri/3`/`otpauth_uri/4`.
217227
218228
## Examples
219229
@@ -226,18 +236,21 @@ defmodule NimbleTOTP do
226236
def verification_code(secret, opts \\ []) when is_binary(secret) and is_list(opts) do
227237
time = opts |> Keyword.get_lazy(:time, fn -> System.os_time(:second) end) |> to_unix()
228238
period = Keyword.get(opts, :period, @default_totp_period)
239+
digits = Keyword.get(opts, :digits, @default_digits)
240+
241+
digits not in 6..10 && raise ArgumentError, "digits must be between 6 and 10"
229242

230-
verification_code(secret, time, period)
243+
verification_code(secret, time, period, digits)
231244
end
232245

233-
@spec verification_code(binary(), integer(), pos_integer()) :: binary()
234-
defp verification_code(secret, time, period) do
246+
@spec verification_code(binary(), integer(), pos_integer(), integer()) :: binary()
247+
defp verification_code(secret, time, period, digits) do
235248
secret
236249
|> hmac(time, period)
237250
|> hmac_truncate()
238-
|> rem(1_000_000)
251+
|> rem(Integer.pow(10, digits))
239252
|> to_string()
240-
|> String.pad_leading(@totp_size, "0")
253+
|> String.pad_leading(digits, "0")
241254
end
242255

243256
defp hmac(secret, time, period) do
@@ -273,6 +286,9 @@ defmodule NimbleTOTP do
273286
* `:period` - The period (in seconds) in which the code is valid. Default is `30`.
274287
If this option is given to `verification_code/2`, it must also be given to `valid?/3`.
275288
289+
* `:digits` - The desired length of the totp. Default is 6.
290+
If this option is given to `verification_code/2`, it must also be given to `valid?/3` and `otpauth_uri/3`/`otpauth_uri/4`.
291+
276292
## Preventing TOTP code reuse
277293
278294
The `:since` option can be used to prevent TOTP codes from being reused. When set
@@ -302,18 +318,30 @@ defmodule NimbleTOTP do
302318
@spec valid?(binary(), String.t(), [option() | validate_option()]) :: boolean()
303319
def valid?(secret, otp, opts \\ [])
304320

305-
def valid?(secret, <<a1, a2, a3, a4, a5, a6>>, opts) do
321+
def valid?(secret, otp, opts) when is_binary(otp) do
306322
time = opts |> Keyword.get(:time, System.os_time(:second)) |> to_unix()
307323
period = Keyword.get(opts, :period, @default_totp_period)
324+
digits = Keyword.get(opts, :digits, @default_digits)
308325

309-
<<e1, e2, e3, e4, e5, e6>> = verification_code(secret, time, period)
326+
digits not in 6..10 && raise ArgumentError, "digits must be between 6 and 10"
310327

311-
(bxor(e1, a1) ||| bxor(e2, a2) ||| bxor(e3, a3) ||| bxor(e4, a4) ||| bxor(e5, a5) |||
312-
bxor(e6, a6)) === 0 and not reused?(time, period, opts)
328+
code = verification_code(secret, time, period, digits)
329+
330+
byte_size(code) == byte_size(otp) and validate_digits(code, otp) == 0 and
331+
not reused?(time, period, opts)
313332
end
314333

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

336+
@spec validate_digits(integer(), integer()) :: :error | integer()
337+
defp validate_digits(<<e, e_rest::binary>>, <<a, a_rest::binary>>) do
338+
bxor(e, a) ||| validate_digits(e_rest, a_rest)
339+
end
340+
341+
defp validate_digits(<<>>, <<>>) do
342+
0
343+
end
344+
317345
@spec reused?(integer(), pos_integer(), [option() | validate_option()]) :: boolean()
318346
defp reused?(time, period, opts) do
319347
if since = Keyword.get(opts, :since) do

test/nimble_totp_test.exs

Lines changed: 33 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -128,9 +128,31 @@ defmodule NimbleTOTPTest do
128128
assert NimbleTOTP.valid?(secret, code, time: date_time)
129129
assert NimbleTOTP.valid?(secret, code, time: naive_date_time)
130130

131-
refute NimbleTOTP.valid?(secret, "abcdef", time: time)
132-
refute NimbleTOTP.valid?(secret, "abcdef", time: date_time)
133-
refute NimbleTOTP.valid?(secret, "abcdef", time: naive_date_time)
131+
refute NimbleTOTP.valid?(secret, "abcdefgh", time: time)
132+
refute NimbleTOTP.valid?(secret, "abcdefgh", time: date_time)
133+
refute NimbleTOTP.valid?(secret, "abcdefgh", time: naive_date_time)
134+
end
135+
end
136+
137+
test "returns true if it matches the verification code with a length of 8" do
138+
time = System.os_time(:second)
139+
date_time = DateTime.from_unix!(time, :second)
140+
naive_date_time = DateTime.to_naive(date_time)
141+
142+
for _ <- 1..1000 do
143+
secret = NimbleTOTP.secret()
144+
145+
code = NimbleTOTP.verification_code(secret, time: time, digits: 8)
146+
assert code == NimbleTOTP.verification_code(secret, time: date_time, digits: 8)
147+
assert code == NimbleTOTP.verification_code(secret, time: naive_date_time, digits: 8)
148+
149+
assert NimbleTOTP.valid?(secret, code, time: time, digits: 8)
150+
assert NimbleTOTP.valid?(secret, code, time: date_time, digits: 8)
151+
assert NimbleTOTP.valid?(secret, code, time: naive_date_time, digits: 8)
152+
153+
refute NimbleTOTP.valid?(secret, "abcdefgh", time: time, digits: 8)
154+
refute NimbleTOTP.valid?(secret, "abcdefgh", time: date_time, digits: 8)
155+
refute NimbleTOTP.valid?(secret, "abcdefgh", time: naive_date_time, digits: 8)
134156
end
135157
end
136158

@@ -161,14 +183,21 @@ defmodule NimbleTOTPTest do
161183
end
162184
end
163185

164-
test "returns false if the code does not have 6 digits" do
186+
test "returns false if the code does not have 6 digits for default totp_length" do
165187
time = System.os_time(:second)
166188
secret = NimbleTOTP.secret()
167189
code = NimbleTOTP.verification_code(secret, time: time)
168190
refute NimbleTOTP.valid?(secret, "", time: time)
169191
refute NimbleTOTP.valid?(secret, binary_part(code, 0, 5), time: time)
170192
refute NimbleTOTP.valid?(secret, <<?0, code::binary>>, time: time)
171193
end
194+
195+
test "returns false if the totp_length is under 6 or above 10" do
196+
time = System.os_time(:second)
197+
secret = NimbleTOTP.secret()
198+
assert_raise ArgumentError, "length must be between 6 and 10", fn -> NimbleTOTP.verification_code(secret, time: time, digits: 5) end
199+
assert_raise ArgumentError, "length must be between 6 and 10", fn -> NimbleTOTP.verification_code(secret, time: time, digits: 11) end
200+
end
172201
end
173202

174203
defp to_unix(naive_datetime),

0 commit comments

Comments
 (0)