comparison sat/memory/crypto.py @ 2562:26edcf3a30eb

core, setup: huge cleaning: - moved directories from src and frontends/src to sat and sat_frontends, which is the recommanded naming convention - move twisted directory to root - removed all hacks from setup.py, and added missing dependencies, it is now clean - use https URL for website in setup.py - removed "Environment :: X11 Applications :: GTK", as wix is deprecated and removed - renamed sat.sh to sat and fixed its installation - added python_requires to specify Python version needed - replaced glib2reactor which use deprecated code by gtk3reactor sat can now be installed directly from virtualenv without using --system-site-packages anymore \o/
author Goffi <goffi@goffi.org>
date Mon, 02 Apr 2018 19:44:50 +0200
parents src/memory/crypto.py@0046283a285d
children 56f94936df1e
comparison
equal deleted inserted replaced
2561:bd30dc3ffe5a 2562:26edcf3a30eb
1 #!/usr/bin/env python2
2 # -*- coding: utf-8 -*-
3
4 # SAT: a jabber client
5 # Copyright (C) 2009-2018 Jérôme Poisson (goffi@goffi.org)
6 # Copyright (C) 2013-2016 Adrien Cossa (souliane@mailoo.org)
7
8 # This program is free software: you can redistribute it and/or modify
9 # it under the terms of the GNU Affero General Public License as published by
10 # the Free Software Foundation, either version 3 of the License, or
11 # (at your option) any later version.
12
13 # This program is distributed in the hope that it will be useful,
14 # but WITHOUT ANY WARRANTY; without even the implied warranty of
15 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
16 # GNU Affero General Public License for more details.
17
18 # 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/>.
20
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
28 from base64 import b64encode, b64decode
29 from twisted.internet.threads import deferToThread
30 from twisted.internet.defer import succeed
31
32
33 class BlockCipher(object):
34
35 BLOCK_SIZE = AES.block_size # 16 bits
36 MAX_KEY_SIZE = AES.key_size[-1] # 32 bits = AES-256
37 IV_SIZE = BLOCK_SIZE # initialization vector size, 16 bits
38
39 @classmethod
40 def encrypt(cls, key, text, leave_empty=True):
41 """Encrypt a message.
42
43 Based on http://stackoverflow.com/a/12525165
44
45 @param key (unicode): the encryption key
46 @param text (unicode): the text to encrypt
47 @param leave_empty (bool): if True, empty text will be returned "as is"
48 @return: Deferred: base-64 encoded str
49 """
50 if leave_empty and text == '':
51 return succeed(text)
52 iv = BlockCipher.getRandomKey()
53 key = key.encode('utf-8')
54 key = key[:BlockCipher.MAX_KEY_SIZE] if len(key) >= BlockCipher.MAX_KEY_SIZE else BlockCipher.pad(key)
55 cipher = AES.new(key, AES.MODE_CFB, iv)
56 d = deferToThread(cipher.encrypt, BlockCipher.pad(text.encode('utf-8')))
57 d.addCallback(lambda ciphertext: b64encode(iv + ciphertext))
58 return d
59
60 @classmethod
61 def decrypt(cls, key, ciphertext, leave_empty=True):
62 """Decrypt a message.
63
64 Based on http://stackoverflow.com/a/12525165
65
66 @param key (unicode): the decryption key
67 @param ciphertext (base-64 encoded str): the text to decrypt
68 @param leave_empty (bool): if True, empty ciphertext will be returned "as is"
69 @return: Deferred: str or None if the password could not be decrypted
70 """
71 if leave_empty and ciphertext == '':
72 return succeed('')
73 ciphertext = b64decode(ciphertext)
74 iv, ciphertext = ciphertext[:BlockCipher.IV_SIZE], ciphertext[BlockCipher.IV_SIZE:]
75 key = key.encode('utf-8')
76 key = key[:BlockCipher.MAX_KEY_SIZE] if len(key) >= BlockCipher.MAX_KEY_SIZE else BlockCipher.pad(key)
77 cipher = AES.new(key, AES.MODE_CFB, iv)
78 d = deferToThread(cipher.decrypt, ciphertext)
79 d.addCallback(lambda text: BlockCipher.unpad(text))
80 # XXX: cipher.decrypt gives no way to make the distinction between
81 # a decrypted empty value and a decryption failure... both return
82 # the empty value. Fortunately, we detect empty passwords beforehand
83 # thanks to the "leave_empty" parameter which is used by default.
84 d.addCallback(lambda text: text.decode('utf-8') if text else None)
85 return d
86
87 @classmethod
88 def getRandomKey(cls, size=None, base64=False):
89 """Return a random key suitable for block cipher encryption.
90
91 Note: a good value for the key length is to make it as long as the block size.
92
93 @param size: key length in bytes, positive or null (default: BlockCipher.IV_SIZE)
94 @param base64: if True, encode the result to base-64
95 @return: str (eventually base-64 encoded)
96 """
97 if size is None or size < 0:
98 size = BlockCipher.IV_SIZE
99 key = urandom(size)
100 return b64encode(key) if base64 else key
101
102 @classmethod
103 def pad(self, s):
104 """Method from http://stackoverflow.com/a/12525165"""
105 bs = BlockCipher.BLOCK_SIZE
106 return s + (bs - len(s) % bs) * chr(bs - len(s) % bs)
107
108 @classmethod
109 def unpad(self, s):
110 """Method from http://stackoverflow.com/a/12525165"""
111 return s[0:-ord(s[-1])]
112
113
114 class PasswordHasher(object):
115
116 SALT_LEN = 16 # 128 bits
117
118 @classmethod
119 def hash(cls, password, salt=None, leave_empty=True):
120 """Hash a password.
121
122 @param password (str): the password to hash
123 @param salt (base-64 encoded str): if not None, use the given salt instead of a random value
124 @param leave_empty (bool): if True, empty password will be returned "as is"
125 @return: Deferred: base-64 encoded str
126 """
127 if leave_empty and password == '':
128 return succeed(password)
129 salt = b64decode(salt)[:PasswordHasher.SALT_LEN] if salt else urandom(PasswordHasher.SALT_LEN)
130 d = deferToThread(PBKDF2, password, salt)
131 d.addCallback(lambda hashed: b64encode(salt + hashed))
132 return d
133
134 @classmethod
135 def verify(cls, attempt, hashed):
136 """Verify a password attempt.
137
138 @param attempt (str): the attempt to check
139 @param hashed (str): the hash of the password
140 @return: Deferred: boolean
141 """
142 leave_empty = hashed == ''
143 d = PasswordHasher.hash(attempt, hashed, leave_empty)
144 d.addCallback(lambda hashed_attempt: hashed_attempt == hashed)
145 return d