diff options
-rw-r--r-- | AUTHORS.rst | 1 | ||||
-rw-r--r-- | cryptography/exceptions.py | 4 | ||||
-rw-r--r-- | cryptography/hazmat/primitives/twofactor/__init__.py | 0 | ||||
-rw-r--r-- | cryptography/hazmat/primitives/twofactor/hotp.py | 54 | ||||
-rw-r--r-- | docs/changelog.rst | 2 | ||||
-rw-r--r-- | docs/exceptions.rst | 6 | ||||
-rw-r--r-- | docs/hazmat/primitives/index.rst | 1 | ||||
-rw-r--r-- | docs/hazmat/primitives/twofactor.rst | 96 | ||||
-rw-r--r-- | tests/hazmat/primitives/twofactor/__init__.py | 0 | ||||
-rw-r--r-- | tests/hazmat/primitives/twofactor/test_hotp.py | 83 |
10 files changed, 246 insertions, 1 deletions
diff --git a/AUTHORS.rst b/AUTHORS.rst index 86267cf1..e9c2f85f 100644 --- a/AUTHORS.rst +++ b/AUTHORS.rst @@ -14,3 +14,4 @@ PGP key fingerprints are enclosed in parentheses. * David Reid <dreid@dreid.org> (0F83 CC87 B32F 482B C726 B58A 9FBF D8F4 DA89 6D74) * Konstantinos Koukopoulos <koukopoulos@gmail.com> (D6BD 52B6 8C99 A91C E2C8 934D 3300 566B 3A46 726E) * Stephen Holsapple <sholsapp@gmail.com> +* Terry Chia <terrycwk1994@gmail.com> diff --git a/cryptography/exceptions.py b/cryptography/exceptions.py index e2542a1f..f9849e2f 100644 --- a/cryptography/exceptions.py +++ b/cryptography/exceptions.py @@ -42,3 +42,7 @@ class InternalError(Exception): class InvalidKey(Exception): pass + + +class InvalidToken(Exception): + pass diff --git a/cryptography/hazmat/primitives/twofactor/__init__.py b/cryptography/hazmat/primitives/twofactor/__init__.py new file mode 100644 index 00000000..e69de29b --- /dev/null +++ b/cryptography/hazmat/primitives/twofactor/__init__.py diff --git a/cryptography/hazmat/primitives/twofactor/hotp.py b/cryptography/hazmat/primitives/twofactor/hotp.py new file mode 100644 index 00000000..0bc4cc56 --- /dev/null +++ b/cryptography/hazmat/primitives/twofactor/hotp.py @@ -0,0 +1,54 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or +# implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import struct + +import six + +from cryptography.exceptions import InvalidToken +from cryptography.hazmat.primitives import constant_time, hmac +from cryptography.hazmat.primitives.hashes import SHA1 + + +class HOTP(object): + def __init__(self, key, length, backend): + + if len(key) < 16: + raise ValueError("Key length has to be at least 128 bits.") + + if length < 6 or length > 8: + raise ValueError("Length of HOTP has to be between 6 to 8.") + + self._key = key + self._length = length + self._backend = backend + + def generate(self, counter): + truncated_value = self._dynamic_truncate(counter) + hotp = truncated_value % (10**self._length) + return "{0:0{1}}".format(hotp, self._length).encode() + + def verify(self, hotp, counter): + if not constant_time.bytes_eq(self.generate(counter), hotp): + raise InvalidToken("Supplied HOTP value does not match") + + def _dynamic_truncate(self, counter): + ctx = hmac.HMAC(self._key, SHA1(), self._backend) + ctx.update(struct.pack(">Q", counter)) + hmac_value = ctx.finalize() + + offset_bits = six.indexbytes(hmac_value, 19) & 0b1111 + + offset = int(offset_bits) + P = hmac_value[offset:offset+4] + return struct.unpack(">I", P)[0] & 0x7fffffff diff --git a/docs/changelog.rst b/docs/changelog.rst index b4c9a55b..c59f2f51 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -4,7 +4,7 @@ Changelog 0.3 - 2014-XX-XX ~~~~~~~~~~~~~~~~ -**In development** +* Added :class:`~cryptography.hazmat.primitives.twofactor.hotp.HOTP`. 0.2 - 2014-02-20 ~~~~~~~~~~~~~~~~ diff --git a/docs/exceptions.rst b/docs/exceptions.rst index 1e31e31c..8ca9df29 100644 --- a/docs/exceptions.rst +++ b/docs/exceptions.rst @@ -36,3 +36,9 @@ Exceptions This is raised when the verify method of a key derivation function's computed key does not match the expected key. + + +.. class:: InvalidToken + + This is raised when the verify method of a one time password function's + computed token does not match the expected token. diff --git a/docs/hazmat/primitives/index.rst b/docs/hazmat/primitives/index.rst index 38ed24c9..5199d493 100644 --- a/docs/hazmat/primitives/index.rst +++ b/docs/hazmat/primitives/index.rst @@ -14,3 +14,4 @@ Primitives rsa constant-time interfaces + twofactor diff --git a/docs/hazmat/primitives/twofactor.rst b/docs/hazmat/primitives/twofactor.rst new file mode 100644 index 00000000..9d661612 --- /dev/null +++ b/docs/hazmat/primitives/twofactor.rst @@ -0,0 +1,96 @@ +.. hazmat:: + +Two-factor Authentication +========================= + +.. currentmodule:: cryptography.hazmat.primitives.twofactor + +This module contains algorithms related to two-factor authentication. + +Currently, it contains an algorithm for generating and verifying +one time password values based on Hash-based message authentication +codes (HMAC). + +.. currentmodule:: cryptography.hazmat.primitives.twofactor.hotp + +.. class:: HOTP(key, length, backend) + + .. versionadded:: 0.3 + + HOTP objects take a ``key`` and ``length`` parameter. The ``key`` + should be randomly generated bytes and is recommended to be 160 bits in + length. The ``length`` parameter controls the length of the generated + one time password and must be >= 6 and <= 8. + + This is an implementation of :rfc:`4226`. + + .. doctest:: + + >>> import os + >>> from cryptography.hazmat.backends import default_backend + >>> from cryptography.hazmat.primitives.twofactor.hotp import HOTP + + >>> key = b"12345678901234567890" + >>> hotp = HOTP(key, 6, backend=default_backend()) + >>> hotp.generate(0) + '755224' + >>> hotp.verify(b"755224", 0) + + :param bytes key: Secret key as ``bytes``. This value must be generated in a + cryptographically secure fashion and be at least 128 bits. + It is recommended that the key be 160 bits. + :param int length: Length of generated one time password as ``int``. + :param backend: A + :class:`~cryptography.hazmat.backends.interfaces.HMACBackend` + provider. + :raises ValueError: This is raised if the provided ``key`` is shorter 128 bits + or if the ``length`` parameter is not between 6 to 8. + + + .. method:: generate(counter) + + :param int counter: The counter value used to generate the one time password. + :return bytes: A one time password value. + + .. method:: verify(hotp, counter) + + :param bytes hotp: The one time password value to validate. + :param bytes counter: The counter value to validate against. + :raises cryptography.exceptions.InvalidToken: This is raised when the supplied HOTP + does not match the expected HOTP. + +Throttling +---------- + +Due to the fact that the HOTP algorithm generates rather short tokens that are 6 - 8 digits +long, brute force attacks are possible. It is highly recommended that the server that +validates the token implement a throttling scheme that locks out the account for a period of +time after a number of failed attempts. The number of allowed attempts should be as low as +possible while still ensuring that usability is not significantly impacted. + +Re-synchronization of the Counter +--------------------------------- + +The server's counter value should only be incremented on a successful HOTP authentication. +However, the counter on the client is incremented every time a new HOTP value is requested. +This can lead to the counter value being out of synchronization between the client and server. + +Due to this, it is highly recommended that the server sets a look-ahead window that allows the +server to calculate the next ``x`` HOTP values and check them against the supplied HOTP value. +This can be accomplished with something similar to the following code. + +.. code-block:: python + + def verify(hotp, counter, look_ahead): + assert look_ahead >= 0 + correct_counter = None + + otp = HOTP(key, 6, default_backend()) + for count in range(counter, counter+look_ahead): + try: + otp.verify(hotp, count) + correct_counter = count + except InvalidToken: + pass + + return correct_counter
\ No newline at end of file diff --git a/tests/hazmat/primitives/twofactor/__init__.py b/tests/hazmat/primitives/twofactor/__init__.py new file mode 100644 index 00000000..e69de29b --- /dev/null +++ b/tests/hazmat/primitives/twofactor/__init__.py diff --git a/tests/hazmat/primitives/twofactor/test_hotp.py b/tests/hazmat/primitives/twofactor/test_hotp.py new file mode 100644 index 00000000..ec619b55 --- /dev/null +++ b/tests/hazmat/primitives/twofactor/test_hotp.py @@ -0,0 +1,83 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or +# implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import os + +import pytest + +from cryptography.exceptions import InvalidToken +from cryptography.hazmat.primitives.twofactor.hotp import HOTP +from cryptography.hazmat.primitives import hashes +from tests.utils import load_vectors_from_file, load_nist_vectors + +vectors = load_vectors_from_file( + "twofactor/rfc-4226.txt", load_nist_vectors) + + +@pytest.mark.supported( + only_if=lambda backend: backend.hmac_supported(hashes.SHA1()), + skip_message="Does not support HMAC-SHA1." +) +@pytest.mark.hmac +class TestHOTP(object): + + def test_invalid_key_length(self, backend): + secret = os.urandom(10) + + with pytest.raises(ValueError): + HOTP(secret, 6, backend) + + def test_invalid_hotp_length(self, backend): + secret = os.urandom(16) + + with pytest.raises(ValueError): + HOTP(secret, 4, backend) + + @pytest.mark.parametrize("params", vectors) + def test_truncate(self, backend, params): + secret = params["secret"] + counter = int(params["counter"]) + truncated = params["truncated"] + + hotp = HOTP(secret, 6, backend) + + assert hotp._dynamic_truncate(counter) == int(truncated.decode(), 16) + + @pytest.mark.parametrize("params", vectors) + def test_generate(self, backend, params): + secret = params["secret"] + counter = int(params["counter"]) + hotp_value = params["hotp"] + + hotp = HOTP(secret, 6, backend) + + assert hotp.generate(counter) == hotp_value + + @pytest.mark.parametrize("params", vectors) + def test_verify(self, backend, params): + secret = params["secret"] + counter = int(params["counter"]) + hotp_value = params["hotp"] + + hotp = HOTP(secret, 6, backend) + + assert hotp.verify(hotp_value, counter) is None + + def test_invalid_verify(self, backend): + secret = b"12345678901234567890" + counter = 0 + + hotp = HOTP(secret, 6, backend) + + with pytest.raises(InvalidToken): + hotp.verify(b"123456", counter) |