summaryrefslogtreecommitdiff
path: root/otp2289/server.py
diff options
context:
space:
mode:
Diffstat (limited to 'otp2289/server.py')
-rw-r--r--otp2289/server.py471
1 files changed, 0 insertions, 471 deletions
diff --git a/otp2289/server.py b/otp2289/server.py
deleted file mode 100644
index 7f742cd..0000000
--- a/otp2289/server.py
+++ /dev/null
@@ -1,471 +0,0 @@
1# -*- coding: utf-8 -*-
2# SPDX-License-Identifier: BSD-2-Clause-FreeBSD
3#
4# Copyright (c) 2020-2022 Simeon Simeonov
5# All rights reserved.
6#
7# Redistribution and use in source and binary forms, with or without
8# modification, are permitted provided that the following conditions
9# are met:
10# 1. Redistributions of source code must retain the above copyright
11# notice, this list of conditions and the following disclaimer.
12# 2. Redistributions in binary form must reproduce the above copyright notice,
13# this list of conditions and the following disclaimer in the documentation
14# and/or other materials provided with the distribution.
15#
16# THIS SOFTWARE IS PROVIDED BY THE AUTHORS ``AS IS'' AND ANY EXPRESS OR
17# IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES
18# OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED.
19# IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY DIRECT, INDIRECT,
20# INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT
21# NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
22# DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
23# THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
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.
26"""A pure Python implementation of the RFC-2289 OTP server"""
27import binascii
28import hashlib
29
30from .generator import OTP_ALGO_MD5, OTPGenerator, OTPGeneratorException
31
32
33class OTPStateException(Exception):
34 """OTPStateException class"""
35
36
37class OTPStoreException(Exception):
38 """OTPStoreException class"""
39
40
41class OTPInvalidResponse(Exception):
42 """OTPInvalidResponse class"""
43
44
45class OTPState:
46 """
47 OTPState class
48
49 The OTPState class represents a single state on the server side that can:
50 - generate a challenge
51 - validate the corresponding generated response from the generator
52 """
53
54 def __init__(
55 self,
56 ot_hex: str,
57 current_step: int,
58 seed: str,
59 hash_algo=OTP_ALGO_MD5,
60 ):
61 """
62 Constructs an OTPState object with the given arguments.
63
64 Keyword Arguments:
65 :param ot_hex: The one-time hex from the last successful authentication
66 or None for a newly initialized sequence.
67 :type ot_hex: str or None
68
69 :param current_step: The current step that is sent with the challenge
70 :type current_step: int
71
72 :param seed: The seed that is sent with the challenge
73 :type seed: str
74
75 :param hash_algo: The hash algo, defaults to OTP_ALGO_MD5
76 :type hash_algo: int or str
77
78 :raises otp2289.OTPStateException: If the input does not validate
79 """
80 # enforce the rfc2289 constraints
81 try:
82 self._seed = OTPGenerator.validate_seed(seed)
83 self._hash_algo = OTPGenerator.validate_hash_algo(hash_algo)
84 self._step = OTPGenerator.validate_step(current_step)
85 except OTPGeneratorException as exp:
86 raise OTPStateException(exp.args[0]) from None
87 self._current_digest = None
88 if ot_hex is not None:
89 self._current_digest = self.validate_hex(ot_hex)
90 self._new_digest_hex = None # set upon a successful validation
91
92 def __repr__(self):
93 """repr implementation"""
94 return (
95 f'{self.__class__} at {id(self)} '
96 f'(ot_hex={self._current_digest}, current_step={self._step}, '
97 f'seed={self._seed}, '
98 f'hash_algo={self._hash_algo})'
99 )
100
101 @property
102 def challenge_string(self) -> str:
103 """challenge_string-property"""
104 # RFC-2289: "...the entire challenge string MUST be
105 # terminated with either a space or a new line."
106 return f'otp-{self._hash_algo} {self._step} {self._seed} '
107
108 @property
109 def current_digest(self) -> bytes:
110 """current_digest-property"""
111 return self._current_digest
112
113 @property
114 def hash_algo(self) -> str:
115 """hash_algo-property"""
116 return self._hash_algo
117
118 @property
119 def seed(self) -> str:
120 """seed-property"""
121 return self._seed
122
123 @property
124 def step(self) -> int:
125 """step-property"""
126 return self._step
127
128 @property
129 def validated(self) -> bool:
130 """validated-property"""
131 return bool(self._new_digest_hex)
132
133 @classmethod
134 def from_dict(cls, dict_obj: dict):
135 """
136 Returns an OTPState object from the dict-object
137
138 :param dict_obj: The dict object
139 :type dict_obj: dict
140
141 :return: A new OTPState object
142 :rtype: otp2289.OTPStore
143 """
144 return cls(**dict_obj)
145
146 @staticmethod
147 def response_to_bytes(response: str) -> bytes:
148 """
149 A wrapper that handles/validates the response as specified by RFC-2289.
150
151 The method first checks if response is a token and tries to convert
152 it to bytes. If that fails, the method assumes that response is a hex.
153 If neither of those attempts succeeds OTPInvalidResponse is raised.
154 It is up to the caller to run another iteration and compare the result
155 to an existing digest in this state.
156
157 :param response: The response to this state (its challenge)
158 :type response: str
159
160 :raises otp2289.OTPInvalidResponse: If the response is corrupt/illegal,
161 but not if it simply does not
162 validate
163
164 :return: The bytes representation of response (if any)
165 :rtype: bytes
166 """
167 try:
168 return OTPGenerator.tokens_to_bytes(response)
169 except OTPGeneratorException:
170 # now assume hex...
171 try:
172 return OTPState.validate_hex(response)
173 except OTPStateException:
174 raise OTPInvalidResponse(
175 'The response is neither a valid token or hex'
176 ) from None
177
178 @staticmethod
179 def validate_hex(ot_hex: str) -> bytes:
180 """
181 Validates the provided hexidigest.
182
183 :param ot_hex: The one-time hex to validate
184 :type ot_hex: str
185
186 :raises otp2289.OTPStateException: If hex does not validate
187
188 :return: The validated hex (without leading 0x) converted to bytes
189 :rtype: bytes
190 """
191 if not isinstance(ot_hex, str):
192 raise OTPStateException('OT-hex must be a str')
193 if ot_hex.startswith('0x'):
194 ot_hex = ot_hex[2:]
195 ot_hex = ot_hex.strip().lower()
196 if len(ot_hex) != 16:
197 raise OTPStateException(
198 'The length of the hex should be 16 '
199 '(representing 64 bits digest)'
200 )
201 try:
202 return binascii.unhexlify(ot_hex)
203 except binascii.Error:
204 raise OTPStateException('Invalid OT-hex') from None
205
206 def get_next_state(self):
207 """
208 Returns the next state for a validated OTPState.
209
210 This is a brand new OTPState object with the same hash_algo and seed
211 where step -= 1 and ot_hex = self._new_digest_hex
212
213 :return: The next OTPState if validated, None otherwise
214 :rtype: otp2289.OTPState or None
215 """
216 if self._new_digest_hex is None:
217 return None
218 return OTPState(
219 self._new_digest_hex,
220 self._step - 1,
221 self._seed,
222 self._hash_algo,
223 )
224
225 def response_validates(
226 self,
227 response: str,
228 store_valid_response: str = True,
229 ) -> bool:
230 """
231 Validates the incoming response as specified by RFC-2289.
232
233 :param response: The response to this state (its challenge)
234 :type response: str
235
236 :param store_valid_response: Should a valid response be stored
237 :type store_valid_response: bool
238
239 :raises otp2289.OTPInvalidResponse: If the response does not match
240 this state
241
242 :return: Returns True if response validates, False otherwise
243 :rtype: bool
244 """
245 # self.response_to_bytes raises OTPInvalidResponse in case response
246 # is corrupt or in a wrong format
247 response_bytes = self.response_to_bytes(response)
248 if self._hash_algo == 'md5':
249 digest = hashlib.md5(response_bytes).digest()
250 if (
251 self._current_digest is None
252 or OTPGenerator.strxor(digest[0:8], digest[8:])
253 == self._current_digest
254 ):
255 if store_valid_response:
256 self._new_digest_hex = binascii.hexlify(
257 response_bytes
258 ).decode()
259 return True
260 return False
261 if self._hash_algo == 'sha1':
262 digest = hashlib.sha1(response_bytes).digest()
263 if (
264 self._current_digest is None
265 or OTPGenerator.sha1_digest_folding(
266 hashlib.sha1(response_bytes).digest()
267 )
268 == self._current_digest
269 ):
270 if store_valid_response:
271 self._new_digest_hex = binascii.hexlify(
272 response_bytes
273 ).decode()
274 return True
275 return False
276 # this should not happen since the hash_algo is validated by the caller
277 raise OTPInvalidResponse(f'Ivalid hash_algo: {self._hash_algo}')
278
279 def to_dict(self) -> dict:
280 """
281 Returns a dict representation of the object.
282
283 This could be the base for a JSON serialization.
284
285 :return: The dict representation of the object
286 :rtype: dict
287 """
288 ot_hex = self._current_digest
289 if ot_hex is not None:
290 ot_hex = binascii.hexlify(self._current_digest).decode()
291 return {
292 'ot_hex': ot_hex,
293 'current_step': self._step,
294 'seed': self._seed,
295 'hash_algo': self._hash_algo,
296 }
297
298
299class OTPStore:
300 """
301 OTPStore class
302
303 A helper / container class that stores OTPState objects in a 2 layered
304 dict structure represented by [domain][key].
305
306 The class could serve as a base class when implementing store backends.
307 """
308
309 def __init__(self, data=None):
310 """
311 Constructs an OTPStore object from data
312
313 :param data: The data object, defaults to None
314 :type data: object or None
315 """
316 self._data = {} # {key1: {state1-data...}, key2: {state2-data...}}
317 self._states = {} # OTPState: (domain, key) - dict
318 if data is not None:
319 self._add_data(data)
320
321 def __contains__(self, state):
322 """membership test"""
323 return state in self._states
324
325 def __iter__(self):
326 """iterator for OTPStore"""
327 return iter(self._data)
328
329 def __len__(self):
330 """len() implementation"""
331 return len(self._data)
332
333 @property
334 def data(self) -> dict:
335 """
336 data-property
337
338 Exposes the entire raw-data structure (dict).
339 Use the high level methods when possible!
340 """
341 return self._data
342
343 @property
344 def states(self) -> dict:
345 """
346 states-property
347
348 Exposes the entire states structure (dict).
349 Use the high level methods when possible!
350 """
351 return self._states
352
353 def add_state(self, key: str, state: OTPState):
354 """
355 Adds an OTPState object with a given key.
356
357 :param key: The key under which to add the state
358 :type key: str
359
360 :param state: The OTPState object
361 :type state: otp2289.OTPState
362
363 :raises otp2289.OTPStoreException: On failure
364 """
365 if not isinstance(key, str):
366 raise OTPStoreException('key must be a str')
367 if not isinstance(state, OTPState):
368 raise OTPStoreException('state must be an OTPState-object')
369 self._data[key] = state
370 self._states[state] = key
371
372 def get(self, key, default=None):
373 """A wrapper for dict.get"""
374 return self._data.get(key, default)
375
376 def items(self):
377 """A wrapper for dict.items"""
378 return self._data.items()
379
380 def pop_state(self, key: str) -> OTPState:
381 """
382 Removes specified key and returns the corresponding OTPState-object.
383
384 :param key: The key
385 :type key: str
386
387 :raises KeyError: If key does not exist
388
389 :raises otp2289.OTPStoreException: On failure
390
391 :return: The state corresponding to the key
392 :rtype: otp2289.OTPState
393 """
394 if not isinstance(key, str):
395 raise OTPStoreException('key must be a str')
396 state = self._data.pop(key)
397 self._states.pop(state)
398 return state
399
400 def response_validates(
401 self,
402 key: str,
403 response: str,
404 store_valid_response: bool = True,
405 ) -> bool:
406 """
407 A method that wraps around OTPState.response_validates and
408 OTPState.get_next_state.
409
410 The response is validated against the OTPState object that corresponds
411 to key (if any). If store_valid_response is True, the state is replaced
412 by the next state on successful validation.
413
414 :param key: The key
415 :type key: str
416
417 :param response: The response to this state (its challenge)
418 :type response: str
419
420 :param store_valid_response: Should a valid response be stored
421 :type store_valid_response: bool
422
423 :raises KeyError: If the key is not present
424
425 :raises otp2289.OTPInvalidResponse: If the response does not match
426 this state
427
428 :return: Returns True if response validates, False otherwise
429 :rtype: bool
430 """
431 state = self._data[key]
432 rvalue = state.response_validates(response, store_valid_response)
433 if rvalue and store_valid_response:
434 next_state = state.get_next_state()
435 self._data[key] = next_state
436 self._states[next_state] = key
437 self._states.pop(state)
438 return rvalue
439
440 def to_dict(self) -> dict:
441 """
442 Returns a dict representation of the object.
443
444 This could be the base for a JSON serialization.
445
446 :return: The dict representation of the object
447 :rtype: dict
448 """
449 return {key: state.to_dict() for key, state in self._data.items()}
450
451 def _add_data(self, dict_obj: dict) -> dict:
452 """
453 Adds data from a dict object (dict_obj).
454
455 This method should probably be either overloaded or wrapped
456 in a child class.
457
458 dict_obj has the following format:
459 {'key': {'ot_hex': val1,
460 'current_step': val2,
461 'seed': val3,
462 'hash_algo': val4},
463 ...., ....}
464
465 :param dict_obj: The dict-object
466 :type dict_obj: dict
467 """
468 if not dict_obj:
469 return
470 for key, state_dict in dict_obj.items():
471 self.add_state(key, OTPState(**state_dict))