summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorSimeon Simeonov2020-04-03 00:00:52 +0200
committerSimeon Simeonov2020-04-03 00:00:52 +0200
commit097aaa49fa99cffec9db74432d7025457c34199e (patch)
tree1af3ab2ec494b71610d299468c818f7ac144ce59
parent2a6299c32e0baf3df06f8e6d6bc8695b951d630d (diff)
Implement OTPStore container class in the server module
-rw-r--r--otp2289/__init__.py12
-rw-r--r--otp2289/server.py240
-rw-r--r--setup.py42
-rw-r--r--tests/test_server.py25
4 files changed, 312 insertions, 7 deletions
diff --git a/otp2289/__init__.py b/otp2289/__init__.py
index 2fba4ea..36d1659 100644
--- a/otp2289/__init__.py
+++ b/otp2289/__init__.py
@@ -29,12 +29,16 @@ from .generator import (OTP_ALGO_MD5,
29 OTPChallengeException, 29 OTPChallengeException,
30 OTPGenerator, 30 OTPGenerator,
31 OTPGeneratorException) 31 OTPGeneratorException)
32from .server import OTPInvalidResponse, OTPState, OTPStateException 32from .server import (OTPInvalidResponse,
33 OTPState,
34 OTPStateException,
35 OTPStore,
36 OTPStoreException)
33 37
34 38
35__author__ = 'Simeon Simeonov' 39__author__ = 'Simeon Simeonov'
36__version__ = '1.0.0' 40__version__ = '1.0.0'
37__license__ = 'BSD-2' 41__license__ = 'BSD 2-Clause'
38 42
39 43
40def int_or_str(value): 44def int_or_str(value):
@@ -54,4 +58,6 @@ __all__ = ['OTP_ALGO_MD5',
54 'OTPGeneratorException', 58 'OTPGeneratorException',
55 'OTPInvalidResponse', 59 'OTPInvalidResponse',
56 'OTPState', 60 'OTPState',
57 'OTPStateException'] 61 'OTPStateException',
62 'OTPStore',
63 'OTPStoreException']
diff --git a/otp2289/server.py b/otp2289/server.py
index 4151026..b765af6 100644
--- a/otp2289/server.py
+++ b/otp2289/server.py
@@ -36,6 +36,10 @@ class OTPStateException(Exception):
36 """OTPStateException class""" 36 """OTPStateException class"""
37 37
38 38
39class OTPStoreException(Exception):
40 """OTPStoreException class"""
41
42
39class OTPInvalidResponse(Exception): 43class OTPInvalidResponse(Exception):
40 """OTPInvalidResponse class""" 44 """OTPInvalidResponse class"""
41 45
@@ -80,10 +84,6 @@ class OTPState:
80 self._current_digest = self.validate_hex(ot_hex) 84 self._current_digest = self.validate_hex(ot_hex)
81 self._new_digest_hex = None # set upon a successful validation 85 self._new_digest_hex = None # set upon a successful validation
82 86
83 def __str__(self):
84 """Duplicate the challenge string"""
85 return f'otp-{self._hash_algo} {self._step} {self._seed} '
86
87 @property 87 @property
88 def challenge_string(self): 88 def challenge_string(self):
89 """challenge_string-property""" 89 """challenge_string-property"""
@@ -92,10 +92,43 @@ class OTPState:
92 return f'otp-{self._hash_algo} {self._step} {self._seed} ' 92 return f'otp-{self._hash_algo} {self._step} {self._seed} '
93 93
94 @property 94 @property
95 def current_digest(self):
96 """current_digest-property"""
97 return self._current_digest
98
99 @property
100 def hash_algo(self):
101 """hash_algo-property"""
102 return self._hash_algo
103
104 @property
105 def seed(self):
106 """seed-property"""
107 return self._seed
108
109 @property
110 def step(self):
111 """step-property"""
112 return self._step
113
114 @property
95 def validated(self): 115 def validated(self):
96 """validated-property""" 116 """validated-property"""
97 return bool(self._new_digest_hex) 117 return bool(self._new_digest_hex)
98 118
119 @classmethod
120 def from_dict(cls, dict_obj):
121 """
122 Returns an OTPState object from the dict-object
123
124 :param dict_obj: The dict object
125 :type dict_obj: dict
126
127 :return: A new OTPState object
128 :rtype: OTPStore
129 """
130 return cls(**dict_obj)
131
99 @staticmethod 132 @staticmethod
100 def response_to_bytes(response): 133 def response_to_bytes(response):
101 """ 134 """
@@ -152,6 +185,23 @@ class OTPState:
152 except binascii.Error: 185 except binascii.Error:
153 raise OTPStateException('Invalid OT-hex') 186 raise OTPStateException('Invalid OT-hex')
154 187
188 def get_next_state(self):
189 """
190 Returns the next state for a validated OTPState.
191
192 This is a brand new OTPState object with the same hash_algo and seed
193 where step -= 1 and ot_hex = self._new_digest_hex
194
195 :return: The next OTPState if validated, None otherwise
196 :rtype: OTPState or None
197 """
198 if self._new_digest_hex is None:
199 return None
200 return OTPState(self._new_digest_hex,
201 self._step - 1,
202 self._seed,
203 self._hash_algo)
204
155 def response_validates(self, response, store_valid_response=True): 205 def response_validates(self, response, store_valid_response=True):
156 """ 206 """
157 Validates the incoming response as specified by RFC-2289. 207 Validates the incoming response as specified by RFC-2289.
@@ -195,3 +245,185 @@ class OTPState:
195 return False 245 return False
196 # this should not happen since the hash_algo is validated by the caller 246 # this should not happen since the hash_algo is validated by the caller
197 raise OTPInvalidResponse(f'Ivalid hash_algo: {self._hash_algo}') 247 raise OTPInvalidResponse(f'Ivalid hash_algo: {self._hash_algo}')
248
249 def to_dict(self):
250 """
251 Returns a dict representation of the object.
252
253 This could be the base for a JSON serialization.
254
255 :return: The dict representation of the object
256 :rtype: dict
257 """
258 return {'ot_hex': binascii.hexlify(self._current_digest).decode(),
259 'current_step': self._step,
260 'seed': self._seed,
261 'hash_algo': self._hash_algo}
262
263
264class OTPStore:
265 """
266 OTPStore class
267
268 A helper / container class that stores OTPState objects in a 2 layered
269 dict structure represented by [domain][key].
270
271 The class could serve as a base class when implementing store backends.
272 """
273 def __init__(self, data=None):
274 """
275 Constructs an OTPStore object from data
276
277 :param data: The data object, defaults to None
278 :type data: object or None
279 """
280 self._data = {} # {key1: {state1-data...}, key2: {state2-data...}}
281 self._states = {} # OTPState: (domain, key) - dict
282 if data is not None:
283 self._add_data(data)
284
285 def __contains__(self, state):
286 """membership test"""
287 return state in self._states
288
289 def __iter__(self):
290 """iterator for OTPStore"""
291 return iter(self._data)
292
293 def __len__(self):
294 """len() implementation"""
295 return len(self._data)
296
297 @property
298 def data(self):
299 """
300 data-property
301
302 Exposes the entire raw-data structure (dict).
303 Use the high level methods when possible!
304 """
305 return self._data
306
307 @property
308 def states(self):
309 """
310 states-property
311
312 Exposes the entire states structure (dict).
313 Use the high level methods when possible!
314 """
315 return self._states
316
317 def add_state(self, key, state):
318 """
319 Adds an OTPState object with a given key.
320
321 :param key: The key under which to add the state
322 :type key: str
323
324 :param state: The OTPState object
325 :type state: OTPState
326
327 :raises OTPStoreException: On failure
328 """
329 if not isinstance(key, str):
330 raise OTPStoreException('key must be a str')
331 if not isinstance(state, OTPState):
332 raise OTPStoreException('state must be an OTPState-object')
333 self._data[key] = state
334 self._states[state] = key
335
336 def get(self, key, default=None):
337 """A wrapper for dict.get"""
338 return self._data.get(key, default)
339
340 def items(self):
341 """A wrapper for dict.items"""
342 return self._data.items()
343
344 def pop_state(self, key):
345 """
346 Removes specified key and returns the corresponding OTPState-object.
347
348 :param key: The key
349 :type key: str
350
351 :raises KeyError: If key does not exist
352
353 :raises OTPStoreException: On failure
354
355 :return: The state corresponding to the key
356 :rtype: OTPState
357 """
358 if not isinstance(key, str):
359 raise OTPStoreException('key must be a str')
360 state = self._data.pop(key)
361 self._states.pop(state)
362 return state
363
364 def response_validates(self, key, response, store_valid_response=True):
365 """
366 A method that wraps around OTPState.response_validates and
367 OTPState.get_next_state.
368
369 The response is validated against the OTPState object that corresponds
370 to key (if any). If store_valid_response is True, the state is replaced
371 by the next state on successful validation.
372
373 :param key: The key
374 :type key: str
375
376 :param response: The response to this state (its challenge)
377 :type response: str
378
379 :param store_valid_response: Should a valid response be stored
380 :type store_valid_response: bool
381
382 :raises KeyError: If the key is not present
383
384 :raises OTPInvalidResponse: If the response does not match this state
385
386 :return: Returns True if response validates, False otherwise
387 :rtype: bool
388 """
389 state = self._data[key]
390 rvalue = state.response_validates(response, store_valid_response)
391 if rvalue and store_valid_response:
392 next_state = state.get_next_state()
393 self._data[key] = next_state
394 self._states[next_state] = key
395 self._states.pop(state)
396 return rvalue
397
398 def to_dict(self):
399 """
400 Returns a dict representation of the object.
401
402 This could be the base for a JSON serialization.
403
404 :return: The dict representation of the object
405 :rtype: dict
406 """
407 return {key: state.to_dict() for key, state in self._data.items()}
408
409 def _add_data(self, dict_obj):
410 """
411 Adds data from a dict object (dict_obj).
412
413 This method should probably be either overloaded or wrapped
414 in a child class.
415
416 dict_obj has the following format:
417 {'key': {'ot_hex': val1,
418 'current_step': val2,
419 'seed': val3,
420 'hash_algo': val4},
421 ...., ....}
422
423 :param dict_obj: The dict-object
424 :type dict_obj: dict
425 """
426 if not dict_obj:
427 return
428 for key, state_dict in dict_obj.items():
429 self.add_state(key, OTPState(**state_dict))
diff --git a/setup.py b/setup.py
new file mode 100644
index 0000000..9edcdac
--- /dev/null
+++ b/setup.py
@@ -0,0 +1,42 @@
1# -*- coding: utf-8 -*-
2
3import setuptools
4
5import otp2289
6
7
8with open('README.md', 'r') as fh:
9 long_description = fh.read()
10
11
12setuptools.setup(
13 name='pyotp2289',
14 version=otp2289.__version__,
15 author=otp2289.__author__,
16 author_email='sgs@pichove.org',
17 description='A pure Python implementation of "A One-Time Password System"',
18 license=otp2289.__license__,
19 long_description=long_description,
20 long_description_content_type='text/markdown',
21 url='https://github.com/blackm0re/pyotp2289',
22 packages=setuptools.find_packages(),
23 exclude_package_data={'': ['.gitignore']},
24 classifiers=[
25 'Development Status :: 5 - Production/Stable',
26 'Intended Audience :: Developers',
27 'License :: OSI Approved :: BSD License',
28 'Programming Language :: Python :: 3 :: Only',
29 'Programming Language :: Python :: 3.6',
30 'Programming Language :: Python :: 3.7',
31 'Programming Language :: Python :: 3.8',
32 'Programming Language :: Python :: Implementation',
33 'Operating System :: OS Independent',
34 'Topic :: Security :: Cryptography'
35 ],
36 keywords='2289 freebsd unix security cryptography otp password',
37 project_urls={
38 'Bug Reports': 'https://github.com/blackm0re/pyotp2289/issues',
39 'Source': 'https://github.com/blackm0re/pyotp2289',
40 },
41 python_requires='>=3.6',
42)
diff --git a/tests/test_server.py b/tests/test_server.py
index c7cdb12..8d24cb8 100644
--- a/tests/test_server.py
+++ b/tests/test_server.py
@@ -24,6 +24,8 @@
24# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF 24# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF
25# THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 25# THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
26"""Tests for otp2289.server""" 26"""Tests for otp2289.server"""
27import json
28
27import pytest 29import pytest
28 30
29import otp2289 31import otp2289
@@ -82,3 +84,26 @@ def test_state_validation_sha1():
82 assert state.response_validates('0xbb9e6ae1979d8ff4') is True 84 assert state.response_validates('0xbb9e6ae1979d8ff4') is True
83 assert state.response_validates('MILT VARY MAST OK SEES WENT') is True 85 assert state.response_validates('MILT VARY MAST OK SEES WENT') is True
84 assert state.validated is True 86 assert state.validated is True
87
88
89def test_store():
90 """Tests the OTPStore functionality"""
91 store_data = {'sgs': {'ot_hex': '0x7965e05436f5029f',
92 'current_step': 1,
93 'seed': 'TeSt',
94 'hash_algo': 'md5'},
95 'blackmore': {'ot_hex': '0x63d936639734385b',
96 'current_step': 1,
97 'seed': 'TeSt',
98 'hash_algo': 'sha1'}}
99 store = otp2289.OTPStore(store_data)
100 assert len(store) == 2
101 assert isinstance(json.dumps(store.to_dict()), str) # serializable?
102 assert store.response_validates('sgs', '0x9e876134d90499dd') is True
103 assert store.response_validates('sgs', '0x9e876134d90499dd') is False
104 sgs_state = store.get('sgs')
105 assert sgs_state in store
106 store.pop_state('sgs')
107 assert bool(store) is True
108 store.pop_state('blackmore')
109 assert bool(store) is False