comparison sat/memory/crypto.py @ 3160:330a5f1d9eea

core (memory/crypto): replaced `PyCrypto` by `cryptography`: `PyCrypto` is unmaintained for years but was used in SàT for password hashing. This patch fixes that by replacing `PyCrypto` by the reference `cryptography` module which is well maintained. The behaviour stays the same (except that previously async `hash`, `encrypt` and `decrypt` methods are now synchronous, as they are quick and using a deferToThread may actually be more resource intensive than using blocking methods). It is planed to improve `memory.crypto` by using more up-to-date cryptography/hashing algorithms in the future. PyCrypto is no more a dependency of SàT
author Goffi <goffi@goffi.org>
date Sun, 09 Feb 2020 23:50:26 +0100
parents 559a625a236b
children be6d91572633
comparison
equal deleted inserted replaced
3159:30e08d904208 3160:330a5f1d9eea
1 #!/usr/bin/env python3 1 #!/usr/bin/env python3
2
3 2
4 # SAT: a jabber client 3 # SAT: a jabber client
5 # Copyright (C) 2009-2020 Jérôme Poisson (goffi@goffi.org) 4 # Copyright (C) 2009-2020 Jérôme Poisson (goffi@goffi.org)
6 # Copyright (C) 2013-2016 Adrien Cossa (souliane@mailoo.org) 5 # Copyright (C) 2013-2016 Adrien Cossa (souliane@mailoo.org)
7 6
16 # GNU Affero General Public License for more details. 15 # GNU Affero General Public License for more details.
17 16
18 # You should have received a copy of the GNU Affero General Public License 17 # You should have received a copy of the GNU Affero General Public License
19 # along with this program. If not, see <http://www.gnu.org/licenses/>. 18 # along with this program. If not, see <http://www.gnu.org/licenses/>.
20 19
21 try:
22 from Crypto.Cipher import AES
23 from Crypto.Protocol.KDF import PBKDF2
24 except ImportError:
25 raise Exception("PyCrypto is not installed.")
26
27 from os import urandom 20 from os import urandom
28 from base64 import b64encode, b64decode 21 from base64 import b64encode, b64decode
29 from twisted.internet.threads import deferToThread 22 from cryptography.hazmat.primitives import hashes
30 from twisted.internet.defer import succeed 23 from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
24 from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
25 from cryptography.hazmat.backends import default_backend
31 26
32 27
33 class BlockCipher(object): 28 crypto_backend = default_backend()
34 29
35 BLOCK_SIZE = AES.block_size # 16 bits 30
36 MAX_KEY_SIZE = AES.key_size[-1] # 32 bits = AES-256 31 class BlockCipher:
32
33 BLOCK_SIZE = 16
34 MAX_KEY_SIZE = 32
37 IV_SIZE = BLOCK_SIZE # initialization vector size, 16 bits 35 IV_SIZE = BLOCK_SIZE # initialization vector size, 16 bits
38 36
39 @classmethod 37 @staticmethod
40 def encrypt(cls, key, text, leave_empty=True): 38 def encrypt(key, text, leave_empty=True):
41 """Encrypt a message. 39 """Encrypt a message.
42 40
43 Based on http://stackoverflow.com/a/12525165 41 Based on http://stackoverflow.com/a/12525165
44 42
45 @param key (unicode): the encryption key 43 @param key (unicode): the encryption key
46 @param text (unicode): the text to encrypt 44 @param text (unicode): the text to encrypt
47 @param leave_empty (bool): if True, empty text will be returned "as is" 45 @param leave_empty (bool): if True, empty text will be returned "as is"
48 @return (D(str)): base-64 encoded encrypted message 46 @return (D(str)): base-64 encoded encrypted message
49 """ 47 """
50 if leave_empty and text == "": 48 if leave_empty and text == "":
51 return succeed(text) 49 return ""
52 iv = BlockCipher.getRandomKey() 50 iv = BlockCipher.getRandomKey()
53 key = key.encode("utf-8") 51 key = key.encode()
54 key = ( 52 key = (
55 key[: BlockCipher.MAX_KEY_SIZE] 53 key[: BlockCipher.MAX_KEY_SIZE]
56 if len(key) >= BlockCipher.MAX_KEY_SIZE 54 if len(key) >= BlockCipher.MAX_KEY_SIZE
57 else BlockCipher.pad(key) 55 else BlockCipher.pad(key)
58 ) 56 )
59 cipher = AES.new(key, AES.MODE_CFB, iv)
60 d = deferToThread(cipher.encrypt, BlockCipher.pad(text.encode("utf-8")))
61 d.addCallback(lambda ciphertext: b64encode(iv + ciphertext))
62 d.addCallback(lambda bytes_cypher: bytes_cypher.decode('utf-8'))
63 return d
64 57
65 @classmethod 58 cipher = Cipher(algorithms.AES(key), modes.CFB8(iv), backend=crypto_backend)
66 def decrypt(cls, key, ciphertext, leave_empty=True): 59 encryptor = cipher.encryptor()
60 encrypted = encryptor.update(BlockCipher.pad(text.encode())) + encryptor.finalize()
61 return b64encode(iv + encrypted).decode()
62
63 @staticmethod
64 def decrypt(key, ciphertext, leave_empty=True):
67 """Decrypt a message. 65 """Decrypt a message.
68 66
69 Based on http://stackoverflow.com/a/12525165 67 Based on http://stackoverflow.com/a/12525165
70 68
71 @param key (unicode): the decryption key 69 @param key (unicode): the decryption key
72 @param ciphertext (base-64 encoded str): the text to decrypt 70 @param ciphertext (base-64 encoded str): the text to decrypt
73 @param leave_empty (bool): if True, empty ciphertext will be returned "as is" 71 @param leave_empty (bool): if True, empty ciphertext will be returned "as is"
74 @return: Deferred: str or None if the password could not be decrypted 72 @return: Deferred: str or None if the password could not be decrypted
75 """ 73 """
76 if leave_empty and ciphertext == "": 74 if leave_empty and ciphertext == "":
77 return succeed("") 75 return ""
78 ciphertext = b64decode(ciphertext) 76 ciphertext = b64decode(ciphertext)
79 iv, ciphertext = ( 77 iv, ciphertext = (
80 ciphertext[: BlockCipher.IV_SIZE], 78 ciphertext[: BlockCipher.IV_SIZE],
81 ciphertext[BlockCipher.IV_SIZE :], 79 ciphertext[BlockCipher.IV_SIZE :],
82 ) 80 )
83 key = key.encode("utf-8") 81 key = key.encode()
84 key = ( 82 key = (
85 key[: BlockCipher.MAX_KEY_SIZE] 83 key[: BlockCipher.MAX_KEY_SIZE]
86 if len(key) >= BlockCipher.MAX_KEY_SIZE 84 if len(key) >= BlockCipher.MAX_KEY_SIZE
87 else BlockCipher.pad(key) 85 else BlockCipher.pad(key)
88 ) 86 )
89 cipher = AES.new(key, AES.MODE_CFB, iv)
90 d = deferToThread(cipher.decrypt, ciphertext)
91 d.addCallback(lambda text: BlockCipher.unpad(text))
92 # XXX: cipher.decrypt gives no way to make the distinction between
93 # a decrypted empty value and a decryption failure... both return
94 # the empty value. Fortunately, we detect empty passwords beforehand
95 # thanks to the "leave_empty" parameter which is used by default.
96 d.addCallback(lambda text: text if text else None)
97 return d
98 87
99 @classmethod 88 cipher = Cipher(algorithms.AES(key), modes.CFB8(iv), backend=crypto_backend)
100 def getRandomKey(cls, size=None, base64=False): 89 decryptor = cipher.decryptor()
90 decrypted = decryptor.update(ciphertext) + decryptor.finalize()
91 return BlockCipher.unpad(decrypted)
92
93 @staticmethod
94 def getRandomKey(size=None, base64=False):
101 """Return a random key suitable for block cipher encryption. 95 """Return a random key suitable for block cipher encryption.
102 96
103 Note: a good value for the key length is to make it as long as the block size. 97 Note: a good value for the key length is to make it as long as the block size.
104 98
105 @param size: key length in bytes, positive or null (default: BlockCipher.IV_SIZE) 99 @param size: key length in bytes, positive or null (default: BlockCipher.IV_SIZE)
109 if size is None or size < 0: 103 if size is None or size < 0:
110 size = BlockCipher.IV_SIZE 104 size = BlockCipher.IV_SIZE
111 key = urandom(size) 105 key = urandom(size)
112 return b64encode(key) if base64 else key 106 return b64encode(key) if base64 else key
113 107
114 @classmethod 108 @staticmethod
115 def pad(self, s): 109 def pad(s):
116 """Method from http://stackoverflow.com/a/12525165""" 110 """Method from http://stackoverflow.com/a/12525165"""
117 bs = BlockCipher.BLOCK_SIZE 111 bs = BlockCipher.BLOCK_SIZE
118 return s + (bs - len(s) % bs) * (chr(bs - len(s) % bs)).encode('utf-8') 112 return s + (bs - len(s) % bs) * (chr(bs - len(s) % bs)).encode()
119 113
120 @classmethod 114 @staticmethod
121 def unpad(self, s): 115 def unpad(s):
122 """Method from http://stackoverflow.com/a/12525165""" 116 """Method from http://stackoverflow.com/a/12525165"""
123 s = s.decode('utf-8') 117 s = s.decode()
124 return s[0 : -ord(s[-1])] 118 return s[0 : -ord(s[-1])]
125 119
126 120
127 class PasswordHasher(object): 121 class PasswordHasher:
128 122
129 SALT_LEN = 16 # 128 bits 123 SALT_LEN = 16 # 128 bits
130 124
131 @classmethod 125 @staticmethod
132 def hash(cls, password, salt=None, leave_empty=True): 126 def hash(password, salt=None, leave_empty=True):
133 """Hash a password. 127 """Hash a password.
134 128
135 @param password (str): the password to hash 129 @param password (str): the password to hash
136 @param salt (base-64 encoded str): if not None, use the given salt instead of a random value 130 @param salt (base-64 encoded str): if not None, use the given salt instead of a random value
137 @param leave_empty (bool): if True, empty password will be returned "as is" 131 @param leave_empty (bool): if True, empty password will be returned "as is"
138 @return: Deferred: base-64 encoded str 132 @return: Deferred: base-64 encoded str
139 """ 133 """
140 if leave_empty and password == "": 134 if leave_empty and password == "":
141 return succeed("") 135 return ""
142 salt = ( 136 salt = (
143 b64decode(salt)[: PasswordHasher.SALT_LEN] 137 b64decode(salt)[: PasswordHasher.SALT_LEN]
144 if salt 138 if salt
145 else urandom(PasswordHasher.SALT_LEN) 139 else urandom(PasswordHasher.SALT_LEN)
146 ) 140 )
147 d = deferToThread(PBKDF2, password, salt)
148 d.addCallback(lambda hashed: b64encode(salt + hashed))
149 d.addCallback(lambda hashed_bytes: hashed_bytes.decode('utf-8'))
150 return d
151 141
152 @classmethod 142 # we use PyCrypto's PBKDF2 arguments while porting to crytography, to stay
153 def compare_hash(cls, hashed_attempt, hashed): 143 # compatible with existing installations. But this is temporary and we need
154 assert isinstance(hashed, str) 144 # to update them to more secure values.
155 return hashed_attempt == hashed 145 kdf = PBKDF2HMAC(
146 # FIXME: SHA1() is not secure, it is used here for historical reasons
147 # and must be changed as soon as possible
148 algorithm=hashes.SHA1(),
149 length=16,
150 salt=salt,
151 iterations=1000,
152 backend=crypto_backend
153 )
154 key = kdf.derive(password.encode())
155 return b64encode(salt + key).decode()
156 156
157 @classmethod 157 @staticmethod
158 def verify(cls, attempt, hashed): 158 def verify(attempt, pwd_hash):
159 """Verify a password attempt. 159 """Verify a password attempt.
160 160
161 @param attempt (str): the attempt to check 161 @param attempt (str): the attempt to check
162 @param hashed (str): the hash of the password 162 @param pwd_hash (str): the hash of the password
163 @return: Deferred: boolean 163 @return: Deferred: boolean
164 """ 164 """
165 assert isinstance(attempt, str) 165 assert isinstance(attempt, str)
166 assert isinstance(hashed, str) 166 assert isinstance(pwd_hash, str)
167 leave_empty = hashed == "" 167 leave_empty = pwd_hash == ""
168 d = PasswordHasher.hash(attempt, hashed, leave_empty) 168 attempt_hash = PasswordHasher.hash(attempt, pwd_hash, leave_empty)
169 d.addCallback(cls.compare_hash, hashed=hashed) 169 assert isinstance(attempt_hash, str)
170 return d 170 return attempt_hash == pwd_hash