Projekt

Obecné

Profil

Stáhnout (17.1 KB) Statistiky
| Větev: | Tag: | Revize:
1 4a40b0d2 Stanislav Král
import re
2 36409852 Stanislav Král
import subprocess
3
import time
4 9a55ea8a Stanislav Král
import random
5 c0aed2f5 Stanislav Král
6 cc51ca2c Stanislav Král
from src.model.subject import Subject
7 181e1196 Jan Pašek
from src.utils.temporary_file import TemporaryFile
8 c0aed2f5 Stanislav Král
9 36409852 Stanislav Král
# encryption method to be used when generating private keys
10 c0aed2f5 Stanislav Král
PRIVATE_KEY_ENCRYPTION_METHOD = "-aes256"
11
12
# openssl executable name
13
OPENSSL_EXECUTABLE = "openssl"
14
15 36409852 Stanislav Král
# format of NOT_BEFORE NOT_AFTER date fields
16
NOT_AFTER_BEFORE_DATE_FORMAT = "%b %d %H:%M:%S %Y %Z"
17
18 3e770afd Jan Pašek
# minimal configuration file to be used for openssl req command
19
# specifies distinguished_name that references empty section only
20
# openssl requires this option to be present
21
MINIMAL_CONFIG_FILE = "[req]\ndistinguished_name = req_distinguished_name\n[req_distinguished_name]\n\n"
22
23
# section to be used to specify extensions when creating a SSCRT
24
SSCRT_SECTION = "sscrt_ext"
25
26
CA_EXTENSIONS = "basicConstraints=critical,CA:TRUE"
27
28 9a55ea8a Stanislav Král
# upper bound of the range of random serial numbers to be generated
29
MAX_SN = 4294967296
30
31 c0aed2f5 Stanislav Král
32
class CryptographyService:
33
34
    @staticmethod
35 57898b2f Stanislav Král
    def __subject_to_param_format(subject):
36
        """
37
        Converts the given subject to a dictionary containing openssl field names mapped to subject's fields
38
        :param subject: subject to be converted
39
        :return: a dictionary containing openssl field names mapped to subject's fields
40
        """
41 6c098d6e Stanislav Král
        subj_dict = {}
42
        if subject.common_name is not None:
43
            subj_dict["CN"] = subject.common_name
44
        if subject.country is not None:
45
            subj_dict["C"] = subject.country
46
        if subject.locality is not None:
47
            subj_dict["L"] = subject.locality
48
        if subject.state is not None:
49
            subj_dict["ST"] = subject.state
50
        if subject.organization is not None:
51
            subj_dict["O"] = subject.organization
52
        if subject.organization_unit is not None:
53
            subj_dict["OU"] = subject.organization_unit
54
        if subject.email_address is not None:
55
            subj_dict["emailAddress"] = subject.email_address
56
57
        # merge the subject into a "subj" parameter format
58
        return "".join([f"/{key}={value}" for key, value in subj_dict.items()])
59
60
    @staticmethod
61 7444d4cb Stanislav Král
    def __run_for_output(args=None, proc_input=None, executable=OPENSSL_EXECUTABLE):
62 c0aed2f5 Stanislav Král
        """
63
        Launches a new process in which the given executable is run. STDIN and process arguments can be set.
64
        If the process ends with a non-zero then <CryptographyException> is raised.
65 4691a56f Stanislav Král
66 c0aed2f5 Stanislav Král
        :param args: Arguments to be passed to the program.
67 6c098d6e Stanislav Král
        :param proc_input: String input to be passed to the stdin of the created process.
68 c0aed2f5 Stanislav Král
        :param executable: Executable to be run (defaults to openssl)
69
        :return: If the process ends with a zero return code then the STDOUT of the process is returned as a byte array.
70
        """
71
        if args is None:
72
            args = []
73
        try:
74
            # prepend the name of the executable
75
            args.insert(0, executable)
76
77
            # create a new process
78 fe647b46 Stanislav Král
            proc = subprocess.Popen(args, stdin=subprocess.PIPE if proc_input is not None else None,
79
                                    stdout=subprocess.PIPE,
80 c0aed2f5 Stanislav Král
                                    stderr=subprocess.PIPE)
81
82 6c098d6e Stanislav Král
            out, err = proc.communicate(proc_input)
83 c0aed2f5 Stanislav Král
84
            if proc.returncode != 0:
85
                # if the process did not result in zero result code, then raise an exception
86 7d0aa304 Stanislav Král
                if err is not None and len(err) > 0:
87 c0aed2f5 Stanislav Král
                    raise CryptographyException(executable, args, err.decode())
88
                else:
89
                    raise CryptographyException(executable, args,
90
                                                f""""Execution resulted in non-zero argument""")
91
92
            return out
93
        except FileNotFoundError:
94
            raise CryptographyException(executable, args, f""""{executable}" not found in the current PATH.""")
95
96
    def create_private_key(self, passphrase=None):
97
        """
98
        Creates a private key with the option to encrypt it using a passphrase.
99
        :param passphrase: A passphrase to be used when encrypting the key (if none is passed then the key is not
100
        encrypted at all). Empty passphrase ("") also results in a key that is not encrypted.
101 18588728 Stanislav Král
        :return: string containing the generated private key in PEM format
102 c0aed2f5 Stanislav Král
        """
103
        if passphrase is None or len(passphrase) == 0:
104 7444d4cb Stanislav Král
            return self.__run_for_output(["genrsa", "2048"]).decode()
105 c0aed2f5 Stanislav Král
        else:
106 7444d4cb Stanislav Král
            return self.__run_for_output(
107 c0aed2f5 Stanislav Král
                ["genrsa", PRIVATE_KEY_ENCRYPTION_METHOD, "-passout", f"pass:{passphrase}", "2048"]).decode()
108
109 9a55ea8a Stanislav Král
    def create_sscrt(self, subject, key, config="", extensions="", key_pass=None, days=30, sn: int =None):
110 c0aed2f5 Stanislav Král
        """
111
        Creates a root CA
112
113
        :param subject: an instance of <Subject> representing the subject to be added to the certificate
114 02f63b07 Stanislav Král
        :param key: private key of the CA to be used
115 c0aed2f5 Stanislav Král
        :param config: string containing the configuration to be used
116
        :param extensions: name of the section in the configuration representing extensions
117 18588728 Stanislav Král
        :param key_pass: passphrase of the private key
118 2f5101f1 Stanislav Král
        :param days: number of days for which the certificate will be valid
119 9a55ea8a Stanislav Král
        :param sn: serial number to be set, when "None" is set a random serial number is generated
120 c0aed2f5 Stanislav Král
121 18588728 Stanislav Král
        :return: string containing the generated certificate in PEM format
122 c0aed2f5 Stanislav Král
        """
123
        assert key is not None
124
        assert subject is not None
125
126 57898b2f Stanislav Král
        subj = self.__subject_to_param_format(subject)
127 c0aed2f5 Stanislav Král
128 3e770afd Jan Pašek
        # To specify extension for creating a SSCRT, one has to use a configuration
129
        # file instead of an extension file. Therefore the following code creates
130
        # the most basic configuration file with sscrt_ext section, that is later
131
        # reference in openssl req command using -extensions option.
132
        extensions += "\n"+CA_EXTENSIONS
133
        if len(config) == 0:
134 9e6f791a Jan Pašek
            config += MINIMAL_CONFIG_FILE
135 3e770afd Jan Pašek
        config += "\n[ " + SSCRT_SECTION + " ]" + "\n" + extensions
136
137 c0aed2f5 Stanislav Král
        with TemporaryFile("openssl.conf", config) as conf_path:
138 2f5101f1 Stanislav Král
            args = ["req", "-x509", "-new", "-subj", subj, "-days", f"{days}",
139 c0aed2f5 Stanislav Král
                    "-key", "-"]
140 3e770afd Jan Pašek
141 9a55ea8a Stanislav Král
            # serial number passed, use it when generating the certificate,
142
            # without passing it openssl generates a random one
143
            if sn is not None:
144
                args.extend(["-set_serial", str(sn)])
145
146 c0aed2f5 Stanislav Král
            if len(config) > 0:
147
                args.extend(["-config", conf_path])
148
            if len(extensions) > 0:
149 3e770afd Jan Pašek
                args.extend(["-extensions", SSCRT_SECTION]) # when creating SSCRT, section references section in config
150 c0aed2f5 Stanislav Král
151
            # it would be best to not send the pass phrase at all, but for some reason pytest then prompts for
152
            # the pass phrase (this does not happen when run from pycharm)
153
154 fe647b46 Stanislav Král
            #  add the passphrase even when None is passed. Otherwise when running tests with pytest some tests freeze
155
            # waiting for the passphrase to be typed in
156 18588728 Stanislav Král
            args.extend(["-passin", f"pass:{key_pass}"])
157 c0aed2f5 Stanislav Král
158 7444d4cb Stanislav Král
            return self.__run_for_output(args, proc_input=bytes(key, encoding="utf-8")).decode()
159 6c098d6e Stanislav Král
160 87fd5afc Stanislav Král
    def __create_csr(self, subject, key, key_pass=""):
161 6c098d6e Stanislav Král
        """
162 bdf9a46c Stanislav Král
        Creates a CSR (Certificate Signing Request)
163 6c098d6e Stanislav Král
164
        :param subject: an instance of <Subject> representing the subject to be added to the CSR
165 87fd5afc Stanislav Král
        :param key: the private key of the subject to be used to generate the CSR
166
        :param key_pass: passphrase of the subject's private key
167 18588728 Stanislav Král
        :return: string containing the generated certificate signing request in PEM format
168 6c098d6e Stanislav Král
        """
169
170 57898b2f Stanislav Král
        subj_param = self.__subject_to_param_format(subject)
171 6c098d6e Stanislav Král
172
        args = ["req", "-new", "-subj", subj_param, "-key", "-"]
173
174 fe647b46 Stanislav Král
        # add the passphrase even when None is passed. Otherwise when running tests with pytest some tests freeze
175
        # waiting for the passphrase to be typed in
176 87fd5afc Stanislav Král
        args.extend(["-passin", f"pass:{key_pass}"])
177 6c098d6e Stanislav Král
178 87fd5afc Stanislav Král
        return self.__run_for_output(args, proc_input=bytes(key, encoding="utf-8")).decode()
179 c0aed2f5 Stanislav Král
180 9a55ea8a Stanislav Král
    def __sign_csr(self, csr, issuer_pem, issuer_key, issuer_key_pass=None, extensions="", days=30, sn: int = None):
181 fe647b46 Stanislav Král
        """
182
        Signs the given CSR by the given issuer CA
183 ad068f9d Stanislav Král
184 fe647b46 Stanislav Král
        :param csr: a string containing the CSR to be signed
185
        :param issuer_pem: string containing the certificate of the issuer that will sign this CSR in PEM format
186
        :param issuer_key: string containing the private key of the issuer's certificate in PEM format
187 9dbbcdae Stanislav Král
        :param issuer_key_pass: string containing the passphrase of the private key of the issuer's certificate in PEM
188
        format
189 fe647b46 Stanislav Král
        :param extensions: extensions to be applied when signing the CSR
190 c4b2f4d2 Stanislav Král
        :param days: number of days for which the certificate will be valid
191 9a55ea8a Stanislav Král
        :param sn: serial number to be set, when "None" is set a random serial number is generated
192 fe647b46 Stanislav Král
        :return: string containing the generated and signed certificate in PEM format
193
        """
194
195
        # concatenate CSR, issuer certificate and issuer's key (will be used in the openssl call)
196
        proc_input = csr + issuer_pem + issuer_key
197
198 9a55ea8a Stanislav Král
        # TODO find a better way to generate a random serial number or let openssl generate a .srl file
199
        # when serial number is not passed generate a random one
200
        if sn is None:
201
            sn = random.randint(0, MAX_SN)
202
203 fe647b46 Stanislav Král
        # prepare openssl parameters...
204
        # CSR, CA and CA's private key will be passed via stdin (that's the meaning of the '-' symbol)
205 5fdd01a6 Stanislav Král
        params = ["x509", "-req", "-in", "-", "-CA", "-", "-CAkey", "-", "-CAcreateserial", "-days", str(days)]
206 fe647b46 Stanislav Král
207
        # TODO delete created -.srl file
208
209
        with TemporaryFile("extensions.conf", extensions) as ext_path:
210
            # add the passphrase even when None is passed. Otherwise when running tests with pytest some tests freeze
211
            # waiting for the passphrase to be typed in
212
            params.extend(["-passin", f"pass:{issuer_key_pass}"])
213
214
            if len(extensions) > 0:
215
                params.extend(["-extfile", ext_path])
216
217 7444d4cb Stanislav Král
            return self.__run_for_output(params, proc_input=(bytes(proc_input, encoding="utf-8"))).decode()
218 fe647b46 Stanislav Král
219 18588728 Stanislav Král
    def create_crt(self, subject, subject_key, issuer_pem, issuer_key, subject_key_pass=None, issuer_key_pass=None,
220 5fdd01a6 Stanislav Král
                   extensions="",
221 9a55ea8a Stanislav Král
                   days=30,
222
                   sn: int = None):
223 9dbbcdae Stanislav Král
        """
224 61a42455 Stanislav Král
        Creates a certificate by using the given subject, subject's key, issuer and its key.
225 ad068f9d Stanislav Král
226 9dbbcdae Stanislav Král
        :param subject: subject to be added to the created certificate
227 18588728 Stanislav Král
        :param subject_key: string containing the private key to be used when creating the certificate in PEM format
228 9dbbcdae Stanislav Král
        :param issuer_key: string containing the private key of the issuer's certificate in PEM format
229
        :param issuer_pem: string containing the certificate of the issuer that will sign this CSR in PEM format
230 36409852 Stanislav Král
        :param subject_key_pass: string containing the passphrase of the private key used when creating the certificate
231
        in PEM format
232 9dbbcdae Stanislav Král
        :param issuer_key_pass: string containing the passphrase of the private key of the issuer's certificate in PEM
233
        format
234
        :param extensions: extensions to be applied when creating the certificate
235 5fdd01a6 Stanislav Král
        :param days: number of days for which the certificate will be valid
236 9a55ea8a Stanislav Král
        :param sn: serial number to be set, when "None" is set a random serial number is generated
237 18588728 Stanislav Král
        :return: string containing the generated certificate in PEM format
238 9dbbcdae Stanislav Král
        """
239 87fd5afc Stanislav Král
        csr = self.__create_csr(subject, subject_key, key_pass=subject_key_pass)
240 87a7a4a5 Stanislav Král
        return self.__sign_csr(csr, issuer_pem, issuer_key, issuer_key_pass=issuer_key_pass, extensions=extensions,
241 9a55ea8a Stanislav Král
                               days=days, sn=sn)
242 5fdd01a6 Stanislav Král
243
    @staticmethod
244 61a42455 Stanislav Král
    def verify_cert(certificate):
245
        """
246
        Verifies whether the given certificate is not expired.
247
248
        :param certificate: certificate to be verified in PEM format
249
        :return: Returns `true` if the certificate is not expired, `false` when expired.
250
        """
251 5fdd01a6 Stanislav Král
        # call openssl to check whether the certificate is valid to this date
252
        args = [OPENSSL_EXECUTABLE, "x509", "-checkend", "0", "-noout", "-text", "-in", "-"]
253
254
        # create a new process
255
        proc = subprocess.Popen(args, stdin=subprocess.PIPE,
256
                                stdout=subprocess.PIPE,
257
                                stderr=subprocess.PIPE)
258
259
        out, err = proc.communicate(bytes(certificate, encoding="utf-8"))
260
261
        # zero return code means that the certificate is valid
262
        if proc.returncode == 0:
263
            return True
264
        elif proc.returncode == 1 and "Certificate will expire" in out.decode():
265
            # 1 return code means that the certificate is invalid but such message has to be present in the proc output
266
            return False
267
        else:
268
            # the process failed because of some other reason (incorrect cert format)
269
            raise CryptographyException(OPENSSL_EXECUTABLE, args, err.decode())
270 9dbbcdae Stanislav Král
271 19e5260d Stanislav Král
    def extract_public_key_from_private_key(self, private_key_pem: str, passphrase=None) -> str:
272 5c748d51 Stanislav Král
        """
273 e8face67 Stanislav Král
        Extracts a public key from the given private key passed in PEM format
274
        :param private_key_pem: PEM data representing the private key from which a public key should be extracted
275
        :param passphrase: passphrase to be provided when the supplied private key is encrypted
276 5c748d51 Stanislav Král
        :return: a string containing the extracted public key in PEM format
277
        """
278 e8face67 Stanislav Král
        args = ["rsa", "-in", "-", "-pubout"]
279
        if passphrase is not None:
280
            args.extend(["-passin", f"pass:{passphrase}"])
281
        return self.__run_for_output(args, proc_input=bytes(private_key_pem, encoding="utf-8")).decode()
282 5c748d51 Stanislav Král
283 19e5260d Stanislav Král
    def extract_public_key_from_certificate(self, cert_pem: str) -> str:
284
        """
285
        Extracts a public key from the given certificate passed in PEM format
286
        :param cert_pem: PEM data representing a certificate from which a public key should be extracted
287
        :return: a string containing the extracted public key in PEM format
288
        """
289
        # extracting public key from a certificate does not seem to require a passphrase even when
290
        # signed using an encrypted PK
291
        args = ["x509", "-in", "-", "-noout", "-pubkey"]
292
        return self.__run_for_output(args, proc_input=bytes(cert_pem, encoding="utf-8")).decode()
293
294 4a40b0d2 Stanislav Král
    def parse_cert_pem(self, cert_pem):
295 cc51ca2c Stanislav Král
        """
296 36409852 Stanislav Král
        Parses the given certificate in PEM format and returns the subject of the certificate and it's NOT_BEFORE
297
        and NOT_AFTER field
298 cc51ca2c Stanislav Král
        :param cert_pem: a certificated in a PEM format to be parsed
299 36409852 Stanislav Král
        :return: a tuple containing a subject, NOT_BEFORE and NOT_AFTER dates
300 cc51ca2c Stanislav Král
        """
301
        # run openssl x509 to view certificate content
302 36409852 Stanislav Král
        args = ["x509", "-noout", "-subject", "-startdate", "-enddate", "-in", "-"]
303 cc51ca2c Stanislav Král
304 36409852 Stanislav Král
        cert_info_raw = self.__run_for_output(args, proc_input=bytes(cert_pem, encoding="utf-8")).decode()
305
306
        # split lines
307
        results = re.split("\n", cert_info_raw)
308 3e770afd Jan Pašek
        subj_line = results[0].strip()
309
        not_before_line = results[1].strip()
310
        not_after_line = results[2].strip()
311 36409852 Stanislav Král
312
        # attempt to extract subject via regex
313
        match = re.search(r"subject=(.*)", subj_line)
314 4a40b0d2 Stanislav Král
        if match is None:
315 cc51ca2c Stanislav Král
            # TODO use logger
316 36409852 Stanislav Král
            print(f"Could not find subject to parse: {subj_line}")
317 cc51ca2c Stanislav Král
            return None
318 4a40b0d2 Stanislav Král
        else:
319 36409852 Stanislav Král
            # find all attributes (key = value)
320
            found = re.findall(r"\s?([^c=\s]+)\s?=\s?([^,\n]+)", match.group(1))
321 cc51ca2c Stanislav Král
            subj = Subject()
322
            for key, value in found:
323
                if key == "C":
324 3e770afd Jan Pašek
                    subj.country = value.strip()
325 cc51ca2c Stanislav Král
                elif key == "ST":
326 3e770afd Jan Pašek
                    subj.state = value.strip()
327 cc51ca2c Stanislav Král
                elif key == "L":
328 3e770afd Jan Pašek
                    subj.locality = value.strip()
329 cc51ca2c Stanislav Král
                elif key == "O":
330 3e770afd Jan Pašek
                    subj.organization = value.strip()
331 cc51ca2c Stanislav Král
                elif key == "OU":
332 3e770afd Jan Pašek
                    subj.organization_unit = value.strip()
333 cc51ca2c Stanislav Král
                elif key == "CN":
334 3e770afd Jan Pašek
                    subj.common_name = value.strip()
335 cc51ca2c Stanislav Král
                elif key == "emailAddress":
336 3e770afd Jan Pašek
                    subj.email_address = value.strip()
337 36409852 Stanislav Král
338
        # extract notBefore and notAfter date fields
339
        not_before = re.search(r"notBefore=(.*)", not_before_line)
340
        not_after = re.search(r"notAfter=(.*)", not_after_line)
341
342
        # if date fields are found parse them into date objects
343
        if not_before is not None:
344 4faab824 Jan Pašek
            not_before = time.strptime(not_before.group(1).strip(), NOT_AFTER_BEFORE_DATE_FORMAT)
345 36409852 Stanislav Král
        if not_after is not None:
346 4faab824 Jan Pašek
            not_after = time.strptime(not_after.group(1).strip(), NOT_AFTER_BEFORE_DATE_FORMAT)
347 36409852 Stanislav Král
348
        # TODO wrapper class?
349
        # return it as a tuple
350
        return subj, not_before, not_after
351 4a40b0d2 Stanislav Král
352 81dbb479 Jan Pašek
    def get_openssl_version(self) -> str:
353
        """
354
        Get version of the OpenSSL installed on the system
355
        :return: version of the OpenSSL as returned from the process
356
        """
357
        return self.__run_for_output(["version"]).decode("utf-8")
358
359 c0aed2f5 Stanislav Král
360
class CryptographyException(Exception):
361
362
    def __init__(self, executable, args, message):
363
        self.executable = executable
364
        self.args = args
365
        self.message = message
366
367
    def __str__(self):
368
        return f"""
369
        EXECUTABLE: {self.executable}
370
        ARGS: {self.args}
371
        MESSAGE: {self.message}
372
        """