diff options
Diffstat (limited to 'src/otp2289/server.py')
| -rw-r--r-- | src/otp2289/server.py | 476 |
1 files changed, 476 insertions, 0 deletions
diff --git a/src/otp2289/server.py b/src/otp2289/server.py new file mode 100644 index 0000000..066cba2 --- /dev/null +++ b/src/otp2289/server.py | |||
| @@ -0,0 +1,476 @@ | |||
| 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""" | ||
| 27 | import binascii | ||
| 28 | import hashlib | ||
| 29 | |||
| 30 | from .generator import OTP_ALGO_MD5, OTPGenerator, OTPGeneratorException | ||
| 31 | |||
| 32 | |||
| 33 | class OTPStateException(Exception): | ||
| 34 | """OTPStateException class""" | ||
| 35 | |||
| 36 | |||
| 37 | class OTPStoreException(Exception): | ||
| 38 | """OTPStoreException class""" | ||
| 39 | |||
| 40 | |||
| 41 | class OTPInvalidResponse(Exception): | ||
| 42 | """OTPInvalidResponse class""" | ||
| 43 | |||
| 44 | |||
| 45 | class 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 | |||
| 299 | class 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 | { | ||
| 460 | 'key': { | ||
| 461 | 'ot_hex': val1, | ||
| 462 | 'current_step': val2, | ||
| 463 | 'seed': val3, | ||
| 464 | 'hash_algo': val4 | ||
| 465 | }, | ||
| 466 | ..., | ||
| 467 | ..., | ||
| 468 | } | ||
| 469 | |||
| 470 | :param dict_obj: The dict-object | ||
| 471 | :type dict_obj: dict | ||
| 472 | """ | ||
| 473 | if not dict_obj: | ||
| 474 | return | ||
| 475 | for key, state_dict in dict_obj.items(): | ||
| 476 | self.add_state(key, OTPState(**state_dict)) | ||
