1
#!/usr/bin/env python3
2

3
# Contest Management System - http://cms-dev.github.io/
4
# Copyright © 2010-2012 Giovanni Mascellani <mascellani@poisson.phc.unipi.it>
5
# Copyright © 2010-2018 Stefano Maggiolo <s.maggiolo@gmail.com>
6
# Copyright © 2010-2012 Matteo Boscariol <boscarim@hotmail.com>
7
# Copyright © 2012 Luca Wehrstedt <luca.wehrstedt@gmail.com>
8
# Copyright © 2017 Valentin Rosca <rosca.valentin2012@gmail.com>
9
#
10
# This program is free software: you can redistribute it and/or modify
11
# it under the terms of the GNU Affero General Public License as
12
# published by the Free Software Foundation, either version 3 of the
13
# License, or (at your option) any later version.
14
#
15
# This program is distributed in the hope that it will be useful,
16
# but WITHOUT ANY WARRANTY; without even the implied warranty of
17
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
18
# GNU Affero General Public License for more details.
19
#
20
# You should have received a copy of the GNU Affero General Public License
21
# along with this program.  If not, see <http://www.gnu.org/licenses/>.
22

23 2
"""Utilities dealing with encryption and randomness."""
24

25 2
import binascii
26 2
import random
27 2
from string import ascii_lowercase
28

29 2
import bcrypt
30 2
from Crypto import Random
31 2
from Crypto.Cipher import AES
32

33 2
from cmscommon.binary import bin_to_hex, hex_to_bin, bin_to_b64, b64_to_bin
34

35

36 2
__all__ = [
37
    "get_random_key", "get_hex_random_key",
38

39
    "encrypt_binary", "decrypt_binary",
40
    "encrypt_number", "decrypt_number",
41

42
    "generate_random_password",
43

44
    "validate_password", "build_password", "hash_password",
45
    "parse_authentication",
46
    ]
47

48

49 2
_RANDOM = Random.new()
50

51

52 2
def get_random_key():
53
    """Generate 16 random bytes, safe to be used as AES key.
54

55
    """
56 2
    return _RANDOM.read(16)
57

58

59 2
def get_hex_random_key():
60
    """Generate 16 random bytes, safe to be used as AES key.
61
    Return it encoded in hexadecimal.
62

63
    """
64 2
    return bin_to_hex(get_random_key())
65

66

67 2
def encrypt_binary(pt, key_hex):
68
    """Encrypt the plaintext with the 16-bytes key.
69

70
    A random salt is added to avoid having the same input being
71
    encrypted to the same output.
72

73
    pt (bytes): the "plaintext" to encode.
74
    key_hex (str): a 16-bytes key in hex (a string of 32 hex chars).
75

76
    return (str): pt encrypted using the key, in a format URL-safe
77
        (more precisely, base64-encoded with alphabet "a-zA-Z0-9.-_").
78

79
    """
80 2
    key = hex_to_bin(key_hex)
81
    # Pad the plaintext to make its length become a multiple of the block size
82
    # (that is, for AES, 16 bytes), using a byte 0x01 followed by as many bytes
83
    # 0x00 as needed. If the length of the message is already a multiple of 16
84
    # bytes, add a new block.
85 2
    pt_pad = pt + b'\01' + b'\00' * (16 - (len(pt) + 1) % 16)
86
    # The IV is a random block used to differentiate messages encrypted with
87
    # the same key. An IV should never be used more than once in the lifetime
88
    # of the key. In this way encrypting the same plaintext twice will produce
89
    # different ciphertexts.
90 2
    iv = get_random_key()
91
    # Initialize the AES cipher with the given key and IV.
92 2
    aes = AES.new(key, AES.MODE_CBC, iv)
93 2
    ct = aes.encrypt(pt_pad)
94
    # Convert the ciphertext in a URL-safe base64 encoding
95 2
    ct_b64 = bin_to_b64(iv + ct)\
96
        .replace('+', '-').replace('/', '_').replace('=', '.')
97 2
    return ct_b64
98

99

100 2
def decrypt_binary(ct_b64, key_hex):
101
    """Decrypt a ciphertext generated by encrypt_binary.
102

103
    ct_b64 (str): the ciphertext as produced by encrypt_binary.
104
    key_hex (str): the 16-bytes key in hex format used to encrypt.
105

106
    return (bytes): the plaintext.
107

108
    raise (ValueError): if the ciphertext is invalid.
109

110
    """
111 1
    key = hex_to_bin(key_hex)
112 1
    try:
113
        # Convert the ciphertext from a URL-safe base64 encoding to a
114
        # bytestring, which contains both the IV (the first 16 bytes) as well
115
        # as the encrypted padded plaintext.
116 1
        iv_ct = b64_to_bin(
117
            ct_b64.replace('-', '+').replace('_', '/').replace('.', '='))
118 1
        aes = AES.new(key, AES.MODE_CBC, iv_ct[:16])
119
        # Get the padded plaintext.
120 1
        pt_pad = aes.decrypt(iv_ct[16:])
121
        # Remove the padding.
122
        # TODO check that the padding is correct, i.e. that it contains at most
123
        # 15 bytes 0x00 preceded by a byte 0x01.
124 1
        pt = pt_pad.rstrip(b'\x00')[:-1]
125 1
        return pt
126 1
    except (TypeError, binascii.Error):
127 1
        raise ValueError('Could not decode from base64.')
128 1
    except ValueError:
129 1
        raise ValueError('Wrong AES cryptogram length.')
130

131

132 2
def encrypt_number(num, key_hex):
133
    """Encrypt an integer number, with the same properties as
134
    encrypt_binary().
135

136
    """
137 2
    hexnum = b"%x" % num
138 2
    return encrypt_binary(hexnum, key_hex)
139

140

141 2
def decrypt_number(enc, key_hex):
142
    """Decrypt an integer number encrypted with encrypt_number().
143

144
    """
145 1
    return int(decrypt_binary(enc, key_hex), 16)
146

147

148 2
def generate_random_password():
149
    """Utility method to generate a random password.
150

151
    return (str): a random string.
152

153
    """
154 1
    return "".join((random.choice(ascii_lowercase) for _ in range(6)))
155

156

157 2
def parse_authentication(authentication):
158
    """Split the given method:password field into its components.
159

160
    authentication (str): an authentication string as stored in the DB,
161
        for example "plaintext:password".
162

163
    return (str, str): the method and the payload
164

165
    raise (ValueError): when the authentication string is not valid.
166

167
    """
168 2
    method, sep, payload = authentication.partition(":")
169

170 2
    if sep != ":":
171 1
        raise ValueError("Authentication string not parsable.")
172

173 2
    return method, payload
174

175

176 2
def validate_password(authentication, password):
177
    """Validate the given password for the required authentication.
178

179
    authentication (str): an authentication string as stored in the db,
180
        for example "plaintext:password".
181
    password (str): the password provided by the user.
182

183
    return (bool): whether password is correct.
184

185
    raise (ValueError): when the authentication string is not valid or
186
        the method is not known.
187

188
    """
189 2
    method, payload = parse_authentication(authentication)
190 2
    if method == "bcrypt":
191 2
        password = password.encode('utf-8')
192 2
        payload = payload.encode('utf-8')
193 2
        try:
194 2
            return bcrypt.hashpw(password, payload) == payload
195 0
        except ValueError:
196 0
            return False
197 2
    elif method == "plaintext":
198 2
        return payload == password
199
    else:
200 1
        raise ValueError("Authentication method not known.")
201

202

203 2
def build_password(password, method="plaintext"):
204
    """Build an auth string from an already-hashed password.
205

206
    password (str): the hashed password.
207
    method (str): the hasing method to use.
208

209
    return (str): the string embedding the method and the password.
210

211
    """
212
    # TODO make sure it's a valid bcrypt hash if method is bcrypt.
213 2
    return "%s:%s" % (method, password)
214

215

216 2
def hash_password(password, method="bcrypt"):
217
    """Hash and build an auth string from a plaintext password.
218

219
    password (str): the password in plaintext.
220
    method (str): the hashing method to use.
221

222
    return (str): the auth string containing the hashed password.
223

224
    raise (ValueError): if the method is not supported.
225

226
    """
227 2
    if method == "bcrypt":
228 1
        password = password.encode('utf-8')
229 1
        payload = bcrypt.hashpw(password, bcrypt.gensalt()).decode('ascii')
230 2
    elif method == "plaintext":
231 2
        payload = password
232
    else:
233 1
        raise ValueError("Authentication method not known.")
234

235 2
    return build_password(payload, method)

Read our documentation on viewing source code .

Loading