Projekt

Obecné

Profil

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