2021-12-04 / Bartłomiej Kurek
Tworzenie certyfikatów SSL w Python (#2 - Server/Client)

W części pierwszej tworzyliśmy główny certyfikat CA przy użyciu Python.
Mając certyfikat urzędu możemy w sposób zautomatyzowany - również przy pomocy Python - tworzyć i podpisywać nim certyfikaty dla usług. Skrypt będzie wyglądał podobnie, natomiast wystawcą certyfikatu (Issuer) będzie nasz urząd, którego wcześniej wytworzony certyfikat załadujemy w programie.

Po co?

W świecie programistycznym istnieje słuszne powiedzenie: "Don't roll your own crypto". Kryptografia to bardzo złożona dziedzina, a tworzenie własnych kryptograficznych mechanizmów bywa bardzo niebezpieczne.
Niemniej, w dzisiejszym świecie procesy deweloperskie są bardzo złożone, infrastruktury mocno rozbudowane i rozproszone.

Środowiska produkcyjne są zazwyczaj zabezpieczone, co wiąże się też z wymogami prawnymi (np. GDPR). Często chcemy jednak, aby nasze środowiska deweloperskie i testowe były zbliżone do środowisk produkcyjnych, abyśmy mieli takie same konfiguracje bezpieczeństwa w każdym z tych wariantów. Skoro na produkcji szyfrujemy dane, zabezpieczamy ich transmisję, to być może chcielibyśmy mieć te same mechanizmy obecne podczas procesów deweloperskich. Skoro w środowisku produkcyjnym wymagamy mechanizmów bezpieczeństwa zawsze, to powinniśmy również je testować.
Przykładowo - jeśli w środowisku produkcyjnym szyfrujemy dane, to nasze testy powinny te wymagania uwzględniać. Jeśli tam wymagamy zawsze transmisji szyfrowanej, to powinniśmy i to testować. Środowiska produkcyjne cechują się jednak najczęściej większym stopniem "stałości" w zakresie platform i narzędzi, natomiast w środowiskach deweloperskich i testowych bywa znacznie więcej różnorakich rozwiązań oraz indywidualnych preferencji deweloperskich (systemy operacyjne, czy narzędzia programistyczne). Do tego dochodzi często spory narzut operacyjny związany z przygotowaniem środowisk testowych (choćby kontenery). Jeśli nie chcemy (lub nie możemy) używać stałych certyfikatów w danym środowisku, to możemy w miarę łatwo zautomatyzować ich generowanie (nawet "w locie").

O ile w środowisku produkcyjnym wymagamy wyższego poziomu zabezpieczeń i mocnych algorytmów kryptograficznych, o tyle w przypadku środowisk testowych nie zawsze skupiamy się już na samych algorytmach kryptograficznych, a sprawdzamy czy zabezpieczenia są obecne i czy konfiguracje spełniają nasze wymagania. Myślę, że w takim przypadku trochę wiedzy o certyfikatach SSL może się przydać, a automatyzacja ich generowania może oszczędzić nam nakładu pracy związanego z różnymi konfiguracjami.

W tym artykule zajmujemy się po prostu generowaniem certyfikatów, nie tworzymy własnych algorytmów kryptografinyczh. Uważać jednak należy i z tym, gdyż udokumentowane standardy są obszerne i skomplikowane, a błędne użycie rozwiązań może dawać złudne poczucie bezpieczeństwa.
Przed przystąpieniem do dalszej treści przypomnę zatem ostrzeżenie: "Don't roll your own crypto".

Tworzenie i podpis certyfikatu usługi

Skrypt w całości z komentarzami:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
import OpenSSL


# Wczytujemy zawartość pliku ca.pem, zawierającego certyfikat i klucz urzędu
ca_data = None
with open("ca.pem", "r") as fh:
    ca_data = fh.read()

# Ładujemy dane urzędu z wczytanej zawartości pliku
ca = OpenSSL.crypto.load_certificate(OpenSSL.crypto.FILETYPE_PEM, ca_data)

# Ładujemy klucz urzędu z tej samej zawartości pliku
ca_key = OpenSSL.crypto.load_privatekey(OpenSSL.crypto.FILETYPE_PEM, ca_data)

# Generujemy parę kluczy (public, private)
key = OpenSSL.crypto.PKey()
key.generate_key(OpenSSL.crypto.TYPE_RSA, 2048)

# Tworzymy certyfikat
cert = OpenSSL.crypto.X509()

# Ustawiamy wersję 3 (numerowanie od zera)
cert.set_version(2)
cert.set_serial_number(1)
cert.gmtime_adj_notBefore(0)
cert.gmtime_adj_notAfter(3600 * 24)

# Ustawiamy dane podmiotu, który chcemy certyfikować
cert.get_subject().CN = "MyServer"
cert.get_subject().C = "XX" # country code
cert.get_subject().ST = "Nowhere"
cert.get_subject().L = "Nowhere"
cert.get_subject().O = "Development"  # noqa: E741
cert.get_subject().OU = "Development"
cert.get_subject().emailAddress = "server@localhost"

# Jako wydawcę certyfikatu podajemy nasz urząd (CA)
cert.set_issuer(ca.get_issuer())

# Do certyfikatu dodajemy wygenerowany klucz publiczny
cert.set_pubkey(key)

# Dodajemy rozszerzenia:
# - CA:FALSE, critical - nowy certyfikat nie będzie certyfikatem urzędu
# - subjectKeyIdentifier nie może być critical
# - authorityKeyIdentifier ustawiamy na nasz urząd. Nie może być critical. 
# - keyUsage ustawiamy na Digital Signature (wymagane dla certyfikatów klientów)
#   i/lub Key Encipherment (wymagane dla certyfikatów serwerów). Mogą być oba razem.
# - extendedKeyUsage - możemy ograniczyć użycie do klienta, serwera
# - subjectAltName może zawierać listę adresów ip lub nazw domenowych (również
#   subdomen /wildcard/), dla których certyfikat będzie poprawny
cert.add_extensions([
    OpenSSL.crypto.X509Extension(b"basicConstraints", True,
                                 b"CA:FALSE"),
    OpenSSL.crypto.X509Extension(b"subjectKeyIdentifier", False, b"hash",
                                 subject=cert),
    OpenSSL.crypto.X509Extension(b"authorityKeyIdentifier", False,
                                 b"keyid:always",
                                 issuer=ca),
    OpenSSL.crypto.X509Extension(b"keyUsage", True,
                                 b"Digital Signature, Key Encipherment"),
    OpenSSL.crypto.X509Extension(b"extendedKeyUsage", True,
                                 b"serverAuth"),
    OpenSSL.crypto.X509Extension(b"subjectAltName", True,
                                 b"IP:127.0.0.1, DNS:dev.lan, DNS:*.dev.lan"),
])

# Podpisujemy certyfikat kluczem naszego urzędu
cert.sign(ca_key, "sha512")

# Pobieramy dane z obiektu certyfikatu
pem_binary = OpenSSL.crypto.dump_certificate(
    OpenSSL.crypto.FILETYPE_PEM,
    cert
)

# Pobieramy dane obiektu klucza prywatnego, stosujemy hasło
passphrase = "password123"

key_binary = OpenSSL.crypto.dump_privatekey(
    OpenSSL.crypto.FILETYPE_PEM,
    key,
    "AES-256-CBC",
    passphrase.encode(),
)

# Zapisujemy certyfikat i klucz do jednego pliku
with open("server.pem", "w") as fh:
    fh.write(pem_binary.decode())
    fh.write(key_binary.decode())

Numer seryjny jest liczbą.
Jeśli chcemy by nasz klucz prywatny nie był chroniony hasłem, to do funkcji dump_privatekey() nie przekazujemy dwóch ostatnich argumentów (cipher, passhprase).
Dane certyfikatu i klucza prywatnego możemy oczywiście zapisać do osobnych plików.
Niżej zobaczymy użycie komend openssl, które pozwalają na ekstrakcję danych certyfikatu/klucza z pojedynczego pliku pem, czy pozbycie się hasła chroniącego klucz prywatny certyfikatu.

Wykonanie i sprawdzenie

Dla przejrzystości, identyfikator klucza mojego urzędu to:

$ openssl x509 -text -in ca.pem | grep "Subject Key Identifier" -A 1
            X509v3 Subject Key Identifier: 
                47:AA:D2:93:9D:4C:2A:01:34:42:49:F7:A8:C6:9E:B4:A8:5E:84:F4

Generuję nowy certyfikat serwera używjąc powyższego skryptu.
Skrypt ładuje certyfikat urzędu (plik ca.pem) oraz dane jego klucza prywatnego, które są chronione hasłem.
Program zapyta więc o to hasło.
Skrypt nazwałem gensrv.py, zatem uruchamiam:

$ python gensrv.py 
Enter PEM pass phrase:

W wyniku otrzymujemy plik server.pem.

$ ls -la server.pem 
-rw-r--r-- 1 me me 3423 Dec  4 23:04 server.pem

Zanim przejdziemy dalej, zweryfikujmy po prostu czy wszystko do tej pory się zgadza:

$ openssl verify -CAfile ca.pem server.pem 
server.pem: OK

Wygląda poprawnie. Oczywiście, jeśli nie podalibyśmy pliku urzędu, weryfikacja nie powiodłaby się, ponieważ system operacyjny nie zna naszego urzędu. Taki błąd wyglądałby następująco:

$ openssl verify server.pem 
CN = MyServer, C = XX, ST = Nowhere, L = Nowhere, O = Development, OU = Development, emailAddress = server@localhost
error 20 at 0 depth lookup: unable to get local issuer certificate
error server.pem: verification failed

Błąd unable to get local issuer certificate oznacza właśnie, że wśród certyfikatów urzędów zainstalowanych (i takoż zaufanych) w systemie, nie występuje urząd, który podpisał ten certyfikat (nasz własny urząd). To wszystko się zgadza.

Wyświetlamy dane certyfikatu (tutaj w krótszej, nieco zredagowanej dla czytelności formie).

$ openssl x509 -text -in server.pem
Certificate:
    Data:
        Version: 3 (0x2)
        Serial Number: 20211204230426 (0x1261c9a60d1a)
        Signature Algorithm: sha512WithRSAEncryption
        Issuer: CN = MyCommonName, C = XX, ST = MyState, L = MyLocation, O = MyOrganization, OU = MyUnitName, emailAddress = me@localhost
        Validity
            Not Before: Dec  4 22:04:26 2021 GMT
            Not After : Dec  5 22:04:26 2021 GMT
        Subject: CN = MyServer, C = XX, ST = Nowhere, L = Nowhere, O = Development, OU = Development, emailAddress = server@localhost
        Subject Public Key Info:
            Public Key Algorithm: rsaEncryption
                RSA Public-Key: (2048 bit)
                Modulus:
                    00:cf:22:0e:bf:e8:5a:[...]
                Exponent: 65537 (0x10001)
        X509v3 extensions:
            X509v3 Basic Constraints: critical
                CA:FALSE
            X509v3 Subject Key Identifier: 
                39:A9:E3:21:7A:08:47:0F:1D:B2:6C:DA:E2:63:1D:A8:F6:72:27:1C
            X509v3 Authority Key Identifier: 
                keyid:47:AA:D2:93:9D:4C:2A:01:34:42:49:F7:A8:C6:9E:B4:A8:5E:84:F4

            X509v3 Key Usage: critical
                Digital Signature, Key Encipherment
            X509v3 Extended Key Usage: critical
                TLS Web Server Authentication
            X509v3 Subject Alternative Name: critical
                IP Address:127.0.0.1, DNS:dev.lan, DNS:*.dev.lan
    Signature Algorithm: sha512WithRSAEncryption
         6d:02:9e:da:96:e4:d8[...]

Widzimy teraz, że odcisk klucza (fingerprint) podmiotu certyfikatu jest faktycznie inny niż odcisk klucza urzędu:

            X509v3 Subject Key Identifier: 
                39:A9:E3:21:7A:08:47:0F:1D:B2:6C:DA:E2:63:1D:A8:F6:72:27:1C
            X509v3 Authority Key Identifier: 
                keyid:47:AA:D2:93:9D:4C:2A:01:34:42:49:F7:A8:C6:9E:B4:A8:5E:84:F4

Identyfikator klucza podmiotu to:
39:A9:E3:21:7A:08:47:0F:1D:B2:6C:DA:E2:63:1D:A8:F6:72:27:1C,

a identyfikator urzędu (jak wyżej podałem), to:
47:AA:D2:93:9D:4C:2A:01:34:42:49:F7:A8:C6:9E:B4:A8:5E:84:F4.

Również same dane identyfikacyjne podmiotu certyfikatu są rózne od danych identyfikujących urząd.
Podmiot certyfikowany - server (Subject):

Subject: CN = MyServer, C = XX, ST = Nowhere, L = Nowhere, O = Development, OU = Development, emailAddress = server@localhost

Urząd (Issuer):

Issuer: CN = MyCommonName, C = XX, ST = MyState, L = MyLocation, O = MyOrganization, OU = MyUnitName, emailAddress = me@localhost

Używamy certyfikatu w usłudze (minimalna aplikacja webowa w Python)

Skoro mamy certyfikat dla usługi, to spróbujmy go użyć. Moglibyśmy się posłużyć jakimś gotowym programem serwerowym, jednak dla minimalizacji przykładu użyję najprostszej aplikacji webowej w FastAPI. Aplikację uruchomię za pomocą uvicorn, któremu przekażę lokalizację pliku z certyfikatem i kluczem serwera.

Najpierw instaluję FastApi oraz uvicorn:

pip install fastapi uvicorn

Kod aplikacji webowej:

import fastapi

app = fastapi.FastAPI()


@app.get("/")
async def hello():
    return "Hello"

Uruchomienie (HTTPS):

$ uvicorn myapp:app --ssl-certfile=server.pem --ssl-keyfile server.pem --port 8443
Enter PEM pass phrase:
INFO:     Started server process [2266279]
INFO:     Waiting for application startup.
INFO:     Application startup complete.
INFO:     Uvicorn running on https://127.0.0.1:8443 (Press CTRL+C to quit)

Jak widać powyżej - nasz klucz prywatny serwera jest wciąż zabezpieczony hasłem, dlatego nie da się wystartować usługi bez podania hasła. Jeśli chcielibyśmy usunąć hasło i pozostawić klucz prywatny bez tego zabezpieczenia (co pozwoli uruchomić aplikację bez podawania hasła), to możemy osiągnąć to używając "openssl rsa". Jako plik wejściowy podaję server.pem, a jako wynikowy - plik server.key, w którym znajdzie się niezabezpieczony klucz prywatny.

$ openssl rsa -in server.pem -out server.key
Enter pass phrase for server.pem:
writing RSA key

Teraz wydając podobną komendę uvicorn (różnica: --ssl-keyfile server.key) mogę uruchomić aplikację bez podania hasła.

$ uvicorn myapp:app --ssl-certfile=server.pem --ssl-keyfile server.key --port 8443
INFO:     Started server process [2270531]
INFO:     Waiting for application startup.
INFO:     Application startup complete.
INFO:     Uvicorn running on https://127.0.0.1:8443 (Press CTRL+C to quit)

Testujemy połączenia i weryfikację certyfikatów

Nasza przykładowa aplikacja została uruchomiona, usługa nasłuchuje na porcie 8443. Jest to aplikacja webowa działająca na protokole HTTPS, a zatem użyję najpierw serii komend konsolowych do komunikacji z nią, a później sprawdzę komunikację z poziomu przeglądarek internetowych.

openssl s_client

s_client to moduł openssl, który pozwala nam nawiązać szyfrowane połączenie na wskazany adres/port.
Posiada również opcję -showcerts, która wyświetli nam informacje o certyfikacie, jakim przedstawia się serwer.

$ openssl s_client -connect 127.0.0.1:8443 -showcerts -CAfile ca.pem

curl (połączenie HTTPS i weryfikacja)

Teraz sprawdzę połączenie przy użyciu curl. Certyfikat urzędu jest self-signed, nie jest zainstalowany w systemie operacyjnym, a zatem curl powinien poinformować o błędzie przy weryfikacji certyfikatu. Sprawdźmy czy tak się dzieje.

$ curl https://127.0.0.1:8443
curl: (60) SSL certificate problem: unable to get local issuer certificate
More details here: https://curl.se/docs/sslcerts.html

curl failed to verify the legitimacy of the server and therefore could not
establish a secure connection to it. To learn more about this situation and
how to fix it, please visit the web page mentioned above.

To się zgadza. Możemy zatem przekazać do curl argument określający ścieżkę pliku z certyfikatem urzędu.
Jeśli nasza aplikacja serwerowa przedstawia się certyfikatem podpisanym przez ten urząd, połączenie powinno się odbyć bez błędu.
Możemy do weryfikacji użyć bezpośrednio pliku ca.pem (certyfikat i klucz), ale zwyczajowo mamy do dyspozycji jedynie certyfikat.
Dla jasności wyeksportujmy więc certyfikat urzędu z pliku ca.pem do pliku ca.crt:

$ openssl x509 -in ca.pem -out ca.crt

A teraz sprawdzamy połączenie curl ze wskazaniem certyfikatu naszego urzędu:

$ curl https://127.0.0.1:8443 --cacert ca.crt
"Hello"

Jak widać nasza aplikacja webowa odpowiedziała "Hello", a połączenie tym razem odbyło się bez błędu,
gdyż program curl sprawdził czy certyfikat aplikacji pochodz faktycznie z naszego urzędu.

Sprawdzamy przeglądarki internetowe

Firefox

Otwieramy stronę aplikacji w nowym oknie w trybie prywatnym:

$ firefox --private-window https://127.0.0.1:8443

Firefox prawidłowo wyświetla monit bezpieczeństwa:
img-border

Sprawdzamy dane certyfikatu w Firefox:

  • dane podmiotu certyfikowanego (Subject)
  • dane urzędu (Issuer)
  • okres ważności certyfikatu

Patrzymy zatem dalej:

  • Subject Alt Names (ikonka wykrzyknika - oznaczenie critical)
  • informacje o kluczu publicznym (algorytm, liczba bitów)
  • przy podpisywaniu użyliśmy algorytmu SHA-512
  • wersja 3
  • odciski (identyfikatory klucza /algortymy SHA-256, SHA-1/)

Sprawdzamy rozszerzenia:
+ certyfikat nie jest certyfikatem urzędowym
+ KeyUsage wskazuje na ustawione przez nas "Digital Signature" oraz "Key Encipherment" (ikonka critical)
+ ExtendedUsage to "Server Authentication" (również critical)
+ identyfikator klucza tego certyfikatu: 39:A9:E3:21:7A:08:47:0F:1D:B2:6C:DA:E2:63:1D:A8:F6:72:27:1C
+ identyfikator klucza urzędu: 47:AA:D2:93:9D:4C:2A:01:34:42:49:F7:A8:C6:9E:B4:A8:5E:84:F4

Wracamy do głównej strony monitu i wyświetlamy jego szczegóły:

Błąd informuje nas: "certificate issuer is uknown", co się zgadza (niezaufany self-signed certificate, niezainstalowany w systemie).

Akceptuję ryzyko i przechodzę do strony aplikacji.
img-border

"Hello".

Chromium

Poziomy sprawdzania certyfikatów przez przeglądarki bywają różne.
Przykładowo chromium odmawia walidacji certyfikatów, które nie zawierają subjectAltName (czyli nie mają wskazanych adresów ip/nazw domenowych).
Dlatego dla pewności podobne sprawdzenie wykonuję w przeglądarce chromium.

$ chromium --incognito https://127.0.0.1:8443 

img-border

Ostrzeżenie faktycznie wskazuje na tę samą przyczynę - certyfikat urzędu nie jest zaufany w moim
systemie operacyjnym.

img-border

Sprawdzam dane certyfikatu:
img-border

Akceptuję ryzyko i przechodzę do aplikacji:
img-border

"Hello".