crypto.py 7.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235
  1. #!/usr/bin/env python3
  2. # Contest Management System - http://cms-dev.github.io/
  3. # Copyright © 2010-2012 Giovanni Mascellani <mascellani@poisson.phc.unipi.it>
  4. # Copyright © 2010-2018 Stefano Maggiolo <s.maggiolo@gmail.com>
  5. # Copyright © 2010-2012 Matteo Boscariol <boscarim@hotmail.com>
  6. # Copyright © 2012 Luca Wehrstedt <luca.wehrstedt@gmail.com>
  7. # Copyright © 2017 Valentin Rosca <rosca.valentin2012@gmail.com>
  8. #
  9. # This program is free software: you can redistribute it and/or modify
  10. # it under the terms of the GNU Affero General Public License as
  11. # published by the Free Software Foundation, either version 3 of the
  12. # License, or (at your option) any later version.
  13. #
  14. # This program is distributed in the hope that it will be useful,
  15. # but WITHOUT ANY WARRANTY; without even the implied warranty of
  16. # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  17. # GNU Affero General Public License for more details.
  18. #
  19. # You should have received a copy of the GNU Affero General Public License
  20. # along with this program. If not, see <http://www.gnu.org/licenses/>.
  21. """Utilities dealing with encryption and randomness."""
  22. import binascii
  23. import random
  24. from string import ascii_lowercase
  25. import bcrypt
  26. from Cryptodome import Random
  27. from Cryptodome.Cipher import AES
  28. from cmscommon.binary import bin_to_hex, hex_to_bin, bin_to_b64, b64_to_bin
  29. __all__ = [
  30. "get_random_key", "get_hex_random_key",
  31. "encrypt_binary", "decrypt_binary",
  32. "encrypt_number", "decrypt_number",
  33. "generate_random_password",
  34. "validate_password", "build_password", "hash_password",
  35. "parse_authentication",
  36. ]
  37. _RANDOM = Random.new()
  38. def get_random_key():
  39. """Generate 16 random bytes, safe to be used as AES key.
  40. """
  41. return _RANDOM.read(16)
  42. def get_hex_random_key():
  43. """Generate 16 random bytes, safe to be used as AES key.
  44. Return it encoded in hexadecimal.
  45. """
  46. return bin_to_hex(get_random_key())
  47. def encrypt_binary(pt, key_hex):
  48. """Encrypt the plaintext with the 16-bytes key.
  49. A random salt is added to avoid having the same input being
  50. encrypted to the same output.
  51. pt (bytes): the "plaintext" to encode.
  52. key_hex (str): a 16-bytes key in hex (a string of 32 hex chars).
  53. return (str): pt encrypted using the key, in a format URL-safe
  54. (more precisely, base64-encoded with alphabet "a-zA-Z0-9.-_").
  55. """
  56. key = hex_to_bin(key_hex)
  57. # Pad the plaintext to make its length become a multiple of the block size
  58. # (that is, for AES, 16 bytes), using a byte 0x01 followed by as many bytes
  59. # 0x00 as needed. If the length of the message is already a multiple of 16
  60. # bytes, add a new block.
  61. pt_pad = pt + b'\01' + b'\00' * (16 - (len(pt) + 1) % 16)
  62. # The IV is a random block used to differentiate messages encrypted with
  63. # the same key. An IV should never be used more than once in the lifetime
  64. # of the key. In this way encrypting the same plaintext twice will produce
  65. # different ciphertexts.
  66. iv = get_random_key()
  67. # Initialize the AES cipher with the given key and IV.
  68. aes = AES.new(key, AES.MODE_CBC, iv)
  69. ct = aes.encrypt(pt_pad)
  70. # Convert the ciphertext in a URL-safe base64 encoding
  71. ct_b64 = bin_to_b64(iv + ct)\
  72. .replace('+', '-').replace('/', '_').replace('=', '.')
  73. return ct_b64
  74. def decrypt_binary(ct_b64, key_hex):
  75. """Decrypt a ciphertext generated by encrypt_binary.
  76. ct_b64 (str): the ciphertext as produced by encrypt_binary.
  77. key_hex (str): the 16-bytes key in hex format used to encrypt.
  78. return (bytes): the plaintext.
  79. raise (ValueError): if the ciphertext is invalid.
  80. """
  81. key = hex_to_bin(key_hex)
  82. try:
  83. # Convert the ciphertext from a URL-safe base64 encoding to a
  84. # bytestring, which contains both the IV (the first 16 bytes) as well
  85. # as the encrypted padded plaintext.
  86. iv_ct = b64_to_bin(
  87. ct_b64.replace('-', '+').replace('_', '/').replace('.', '='))
  88. aes = AES.new(key, AES.MODE_CBC, iv_ct[:16])
  89. # Get the padded plaintext.
  90. pt_pad = aes.decrypt(iv_ct[16:])
  91. # Remove the padding.
  92. # TODO check that the padding is correct, i.e. that it contains at most
  93. # 15 bytes 0x00 preceded by a byte 0x01.
  94. pt = pt_pad.rstrip(b'\x00')[:-1]
  95. return pt
  96. except (TypeError, binascii.Error):
  97. raise ValueError('Could not decode from base64.')
  98. except ValueError:
  99. raise ValueError('Wrong AES cryptogram length.')
  100. def encrypt_number(num, key_hex):
  101. """Encrypt an integer number, with the same properties as
  102. encrypt_binary().
  103. """
  104. hexnum = b"%x" % num
  105. return encrypt_binary(hexnum, key_hex)
  106. def decrypt_number(enc, key_hex):
  107. """Decrypt an integer number encrypted with encrypt_number().
  108. """
  109. return int(decrypt_binary(enc, key_hex), 16)
  110. def generate_random_password():
  111. """Utility method to generate a random password.
  112. return (str): a random string.
  113. """
  114. return "".join((random.choice(ascii_lowercase) for _ in range(6)))
  115. def parse_authentication(authentication):
  116. """Split the given method:password field into its components.
  117. authentication (str): an authentication string as stored in the DB,
  118. for example "plaintext:password".
  119. return (str, str): the method and the payload
  120. raise (ValueError): when the authentication string is not valid.
  121. """
  122. method, sep, payload = authentication.partition(":")
  123. if sep != ":":
  124. raise ValueError("Authentication string not parsable.")
  125. return method, payload
  126. def validate_password(authentication, password):
  127. """Validate the given password for the required authentication.
  128. authentication (str): an authentication string as stored in the db,
  129. for example "plaintext:password".
  130. password (str): the password provided by the user.
  131. return (bool): whether password is correct.
  132. raise (ValueError): when the authentication string is not valid or
  133. the method is not known.
  134. """
  135. method, payload = parse_authentication(authentication)
  136. if method == "bcrypt":
  137. password = password.encode('utf-8')
  138. payload = payload.encode('utf-8')
  139. try:
  140. return bcrypt.hashpw(password, payload) == payload
  141. except ValueError:
  142. return False
  143. elif method == "plaintext":
  144. return payload == password
  145. else:
  146. raise ValueError("Authentication method not known.")
  147. def build_password(password, method="plaintext"):
  148. """Build an auth string from an already-hashed password.
  149. password (str): the hashed password.
  150. method (str): the hasing method to use.
  151. return (str): the string embedding the method and the password.
  152. """
  153. # TODO make sure it's a valid bcrypt hash if method is bcrypt.
  154. return "%s:%s" % (method, password)
  155. def hash_password(password, method="bcrypt"):
  156. """Hash and build an auth string from a plaintext password.
  157. password (str): the password in plaintext.
  158. method (str): the hashing method to use.
  159. return (str): the auth string containing the hashed password.
  160. raise (ValueError): if the method is not supported.
  161. """
  162. if method == "bcrypt":
  163. password = password.encode('utf-8')
  164. payload = bcrypt.hashpw(password, bcrypt.gensalt()).decode('ascii')
  165. elif method == "plaintext":
  166. payload = password
  167. else:
  168. raise ValueError("Authentication method not known.")
  169. return build_password(payload, method)