-
Notifications
You must be signed in to change notification settings - Fork 23
Expand file tree
/
Copy pathnimble_totp.ex
More file actions
379 lines (281 loc) · 15.3 KB
/
Copy pathnimble_totp.ex
File metadata and controls
379 lines (281 loc) · 15.3 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
defmodule NimbleTOTP do
@moduledoc ~S"""
NimbleTOTP is a tiny library for Two-factor authentication (2FA) that
allows developers to implement Time-Based One-Time Passwords (TOTP)
for their applications.
## Two-factor authentication (2FA)
The concept of 2FA is quite simple. It's an extra layer of security
that demands a user to provide two pieces of evidence (factors) to
the authentication system before access can be granted.
One way to implement 2FA is to generate a random secret for the user
and whenever the system needs to perform a critical action it will
ask the user to enter a validation code. This validation code is a
Time-Based One-Time Password (TOTP) based on the user's secret and can be
provided by an authentication app like Google Authenticator or Authy, which
should be previously installed and configured on a compatible device, e.g.
a smartphone.
> **Note:** A critical action can mean different things depending on
the application. For instance, while in a banking system the login itself
is already considered a critical action, in other systems a user may
be allowed to log in using just the password and only when trying to
update critical data (e.g. its profile) 2FA will be required.
## Using NimbleTOTP
In order to allow developers to implement 2FA, NimbleTOTP provides functions to:
* Generate secrets composed of random bytes.
* Generate URIs to be encoded in a QR Code.
* Generate Time-Based One-Time Passwords (TOTPs) based on a secret.
### Generating the secret
The first step to set up 2FA for a user is to generate (and later persist) its random
secret. You can achieve that using `NimbleTOTP.secret/1`.
Example:
secret = NimbleTOTP.secret()
#=> <<178, 117, 46, 7, 172, 202, 108, 127, 186, 180, ...>>
By default, a binary with 20 random bytes is generated per the
[HOTP RFC](https://tools.ietf.org/html/rfc4226#section-4).
### Generating URIs for QR Code
Before persisting the secret, you need to make sure the user has already
configured the authentication app in a compatible device. The most common
way to do that is to generate a QR Code that can be read by the app.
You can use `NimbleTOTP.otpauth_uri/4` along with
[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()
#=> "<?xml version=\\"1.0\\" standalone=\\"yes\\"?>\\n<svg version=\\"1.1\\" ...
uri = NimbleTOTP.otpauth_uri("Acme", "alice", secret, digits: 8, algorithm: :sha512)
#=> "otpauth://totp/Acme:alice?secret=MFRGGZA&issuer=Acme&digits=8&algorithm=sha512"
uri |> EQRCode.encode() |> EQRCode.svg()
#=> "<?xml version=\\"1.0\\" standalone=\\"yes\\"?>\\n<svg version=\\"1.1\\" ...
### Generating a Time-Based One-Time Password
After successfully reading the QR Code, the app will start generating a
different 6 digit code every `30s`. You can compute the verification code
with:
NimbleTOTP.verification_code(secret)
#=> "569777"
The code can be validated using the `valid?/3` function. Example:
NimbleTOTP.valid?(secret, "569777")
#=> true
NimbleTOTP.valid?(secret, "012345")
#=> false
After validating the code, you can finally persist the user's secret so you use
it later whenever you need to authorize any critical action using 2FA, by using
the same `valid?/2` function.
## Grace period
When you generate a verification code, the code will be valid between `0..period`
seconds, where the default period is `30s`. This means that, in the worst case
scenario, you may generate a verification code that will become invalid in the
next second.
Depending on how you require users to generate codes, it might be beneficial to allow for a
larger validity window for the codes. For example, that might be useful if you deliver
codes through potentially-slow mediums (like SMS). In this case, consider a number of
"previous codes" also valid. To do this, use the `:time` option in `valid?/3` (see the
function documentation for more examples).
See the [TOTP RFC](https://datatracker.ietf.org/doc/html/rfc6238#section-5.2)
for security and usability implications.
## Preventing codes from being reused
The [TOTP RFC](https://tools.ietf.org/html/rfc6238#section-5.2) requires that a
*valid code* can only be used once. This is a security feature that prevents codes from
being reused. For example, a user could legitimately log in with a code, but in the validity
window an attacker could gain access to the code and *also* log in.
To ensure codes are only considered valid if they have not been
used, you need to keep track of the last time the user entered a valid TOTP code. For example,
you can do that in a database column. Then, you can use the `:since` option in `valid?/3`:
NimbleTOTP.valid?(user.totp_secret, code, since: user.last_totp_at)
Assuming the `code` itself is valid for the given secret:
* If `:since` is `nil`, the code will be considered valid.
* If since is given, it will not allow codes in the same time period (30 seconds by default)
to be reused. The user will have to wait for the next code to be generated.
## Preventing enumeration attacks
If you only store the last time a user entered a *valid* TOTP code, you can [prevent
a valid code from being reused](#module-preventing-codes-from-being-reused). However,
this approach by itself doesn't generally prevent an attacker from attempting to "guess"
a valid code by **enumerating** codes. TOTP codes are somewhat short, at only one million
possible values. You'll likely have other rate-limiting mechanisms in place that
would prevent an attacker from attempting codes in rapid sequence, but if you don't, you'll
also want to limit the number of attempts in some way.
"""
import Bitwise
@default_digits 6
@default_totp_period 30
@default_algorithm :sha
@typedoc "Unix time in seconds, `t:DateTime.t/0` or `t:NaiveDateTime.t/0`."
@type time() :: DateTime.t() | NaiveDateTime.t() | integer()
@typedoc "Options for `verification_code/2` and `valid?/3`."
@type option() :: {:time, time()} | {:period, pos_integer()}
@typedoc "Options for `valid?/3`."
@type validate_option() :: {:since, time() | nil}
@doc """
Generate the URI to be encoded in the QR code.
## Examples
iex> NimbleTOTP.otpauth_uri("Acme", "alice", "abcd")
"otpauth://totp/Acme:alice?secret=MFRGGZA&issuer=Acme"
iex> NimbleTOTP.otpauth_uri("Acme", "alice", "abcd", extra: "some_value")
"otpauth://totp/Acme:alice?secret=MFRGGZA&issuer=Acme&extra=some_value"
"""
@spec otpauth_uri(String.t(), String.t(), <<>>, keyword()) :: String.t()
def otpauth_uri(issuer, account, secret, uri_params)
when is_binary(issuer) and is_binary(account) and is_binary(secret) and is_list(uri_params) do
issuer =~ ":" && raise ArgumentError, "issuer cannot have :"
account =~ ":" && raise ArgumentError, "account cannot have :"
key = Base.encode32(secret, padding: false)
params = uri_params |> Keyword.put(:issuer, issuer) |> Keyword.put(:secret, key)
query = URI.encode_query(params, :rfc3986)
"otpauth://totp/#{URI.encode(issuer)}:#{URI.encode(account)}?#{query}"
end
@doc """
Generate the URI to be encoded in the QR code.
This function is deprecated, use `otpauth_uri/4` which has a safer API.
## Examples
iex> NimbleTOTP.otpauth_uri("Acme:alice", "abcd", issuer: "Acme")
"otpauth://totp/Acme:alice?secret=MFRGGZA&issuer=Acme"
"""
@spec otpauth_uri(String.t(), String.t(), keyword() | <<>>) :: String.t()
def otpauth_uri(label, secret, uri_params \\ [])
def otpauth_uri(label, secret, uri_params)
when is_binary(label) and is_binary(secret) and is_list(uri_params) do
key = Base.encode32(secret, padding: false)
params = [{:secret, key} | uri_params]
query = URI.encode_query(params, :rfc3986)
"otpauth://totp/#{URI.encode(label)}?#{query}"
end
def otpauth_uri(issuer, account, secret)
when is_binary(issuer) and is_binary(account) and is_binary(secret) do
otpauth_uri(issuer, account, secret, [])
end
@doc """
Generate a binary composed of random bytes.
The number of bytes is defined by the `size` argument. Default is `20` per the
[HOTP RFC](https://tools.ietf.org/html/rfc4226#section-4).
## Examples
NimbleTOTP.secret()
#=> <<178, 117, 46, 7, 172, 202, 108, 127, 186, 180, ...>>
"""
@spec secret(non_neg_integer()) :: binary()
def secret(size \\ 20) when is_integer(size) and size >= 0 do
:crypto.strong_rand_bytes(size)
end
@doc """
Generate Time-Based One-Time Password (TOTP).
## Options
* `:time` - The time (either `t:NaiveDateTime.t/0`, `t:DateTime.t/0`, or Unix format
*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`.
* `: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`.
* `:algorithm` - The algorithm to use for the totp. Default is :sha.
If this option is given to `verification_code/2`, it must also be given to `valid?/3` and `otpauth_uri/3`/`otpauth_uri/4`.
## Examples
secret = Base.decode32!("PTEPUGZ7DUWTBGMW4WLKB6U63MGKKMCA")
NimbleTOTP.verification_code(secret)
#=> "569777"
"""
@spec verification_code(binary(), [option()]) :: String.t()
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)
digits = Keyword.get(opts, :digits, @default_digits)
algorithm = Keyword.get(opts, :algorithm, @default_algorithm)
digits not in 6..10 && raise ArgumentError, "digits must be between 6 and 10"
algorithm not in [:sha, :sha256, :sha512] &&
raise ArgumentError, "algorithm must be one of :sha, :sha256, :sha512"
verification_code(secret, time, period, digits, algorithm)
end
@spec verification_code(binary(), integer(), pos_integer(), integer(), atom()) :: binary()
defp verification_code(secret, time, period, digits, algorithm) do
secret
|> hmac(time, period, algorithm)
|> hmac_truncate()
|> rem(Integer.pow(10, digits))
|> to_string()
|> String.pad_leading(digits, "0")
end
defp hmac(secret, time, period, algorithm) do
moving_factor = <<Integer.floor_div(time, period)::64>>
hmac_sha(secret, moving_factor, algorithm)
end
defp hmac_sha(key, data, algorithm), do: :crypto.mac(:hmac, algorithm, key, data)
defp hmac_truncate(hmac) do
key_length = byte_size(hmac) - 1
<<_::size(^key_length)-binary, _::4, offset::4>> = hmac
<<_::size(^offset)-binary, p::4-binary, _::binary>> = hmac
<<_::1, bits::31>> = p
bits
end
@doc """
Checks if the given `otp` code matches the given `secret`.
## Options
* `:time` - The time (either `t:NaiveDateTime.t/0`, `t:DateTime.t/0`, or Unix format
*in seconds*) to be used. Default is `System.os_time(:second)`.
* `:since` - The last time the secret was used, see "Preventing TOTP code reuse" next.
Same possible types as the `:time` option.
* `: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`.
* `:algorithm` - The algorithm to use for the totp. Default is :sha.
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
to the time when the last code was entered, the code generated from within that
period is no longer considered valid. Periods are counted from the Unix epoch.
This means a user may have to wait, in the worst case scenario, for the duration
of `:period` before they can enter a valid code again. This implementation meets the
[TOTP RFC](https://datatracker.ietf.org/doc/html/rfc6238#section-5.2) requirements.
## Grace period
In some cases it is preferable to allow the user more time to validate the code.
Generated codes are valid between `0..period` seconds, which means in the worst
case scenario a generated code may be about to expire. You can increase this
interval, the so-called grace period, by using the `:time` option:
def valid_code?(secret, otp) do
time = System.os_time(:second)
NimbleTOTP.valid?(secret, otp, time: time) or
NimbleTOTP.valid?(secret, otp, time: time - 30)
end
In this example by validating first against the current time, but also against
30 seconds ago, we allow the _previous_ code, to be still valid.
A grace period can also prevent flaky tests which would occur when a code is
generated right before a time boundary, then considered invalid.
"""
@spec valid?(binary(), String.t(), [option() | validate_option()]) :: boolean()
def valid?(secret, otp, opts \\ [])
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)
algorithm = Keyword.get(opts, :algorithm, @default_algorithm)
digits not in 6..10 && raise ArgumentError, "digits must be between 6 and 10"
algorithm not in [:sha, :sha256, :sha512] &&
raise ArgumentError, "algorithm must be one of :sha, :sha256, :sha512"
code = verification_code(secret, time, period, digits, algorithm)
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()) :: integer()
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
0
end
@spec reused?(integer(), pos_integer(), [option() | validate_option()]) :: boolean()
defp reused?(time, period, opts) do
if since = Keyword.get(opts, :since) do
Integer.floor_div(time, period) <= Integer.floor_div(to_unix(since), period)
else
false
end
end
@spec to_unix(NaiveDateTime.t()) :: integer()
defp to_unix(%NaiveDateTime{} = naive_date_time),
do: NaiveDateTime.diff(naive_date_time, ~N[1970-01-01 00:00:00])
@spec to_unix(DateTime.t()) :: integer()
defp to_unix(%DateTime{} = date_time), do: DateTime.to_unix(date_time)
@spec to_unix(integer()) :: integer()
defp to_unix(epoch) when is_integer(epoch), do: epoch
end