Przykład implementacji walidacji podpisu żądania RSA w Python Flask
W poprzednich artykułach opisałem, jak można wykorzystać oprogramowanie ZAP i Burp do testowania bezpieczeństwa aplikacji internetowych, które implementują walidację podpisu żądania przy użyciu kluczy RSA. Na podstawie zainteresowania tymi artykułami doszedłem do wniosku, że warto również przedstawić przykład implementacji z drugiej strony, a mianowicie jak można to zaimplementować po stronie serwera. W tym artykule pokażę, jak można samodzielnie zaimplementować walidację podpisu żądania przy użyciu kluczy RSA w prosty sposób, używając języka Python i frameworka Flask.
Wprowadzenie
Podpisywanie żądań to skuteczny mechanizm mający na celu zabezpieczenie aplikacji internetowej przed atakami, zwłaszcza tymi opartymi na modyfikacjach żądań wysyłanych do serwera. Stanowi dodatkową warstwę bezpieczeństwa, współpracując z protokołem HTTPS i chroniąc przed potencjalnymi atakami typu Man-In-The-Middle. W niektórych przypadkach, takich jak w AWS S3, podpisywanie żądań może również pełnić funkcję uwierzytelniania, gdzie tylko klient i serwer mają wiedzę o metodzie tworzenia podpisu.
Najpopularniejszym mechanizmem podpisywania żądań jest HMAC, oparty na funkcjach skrótu takich jak SHA-2, SHA-1 lub MD5. Jednak ten artykuł koncentruje się na drugim podejściu, a mianowicie podpisywaniu żądań przy użyciu kluczy RSA. W tym przypadku nadawca generuje sygnaturę przy użyciu klucza prywatnego, a odbiorca weryfikuje ją przy użyciu publicznego klucza nadawcy.
Warto zauważyć, że istnieją różnice między tymi dwoma metodami. W przypadku HMAC używany jest wcześniej ustalony wspólny tajny klucz, co może stanowić ryzyko, ponieważ kompromitacja klucza pozwoliłaby napastnikowi podszyć się pod nadawcę. Z drugiej strony, RSA używa dwóch kluczy - prywatnego do generowania sygnatury przez nadawcę i publicznego do weryfikacji przez odbiorcę. Jednak każda para kluczy musi być unikalna, co może stanowić wyzwanie.
Przedstawione mechanizmy podpisywania żądań są skutecznymi narzędziami w komplikowaniu manipulacji żądaniami i zwiększaniu odporności systemu na ataki. Wybór między HMAC a RSA powinien być dostosowany do konkretnej natury aplikacji, przy czym podpisywanie żądań oparte na RSA jest szczególnie zalecane dla aplikacji zajmujących się danymi medycznymi lub finansowymi.
Jednak należy pamiętać, że implementacja takich mechanizmów wymaga uwagi. Przesyłanie i przechowywanie kluczy RSA po obu stronach nadawcy i odbiorcy stanowi wyzwanie, szczególnie w kontekście bezpieczeństwa dystrybucji kluczy i zarządzania nimi. Dodatkowo, użycie wartości nonce i znaczników czasowych w połączeniu z sygnaturą może skutecznie chronić przed atakami powtórzeniowymi (ang. replay attack).
W implementacji walidacji sygnatury żądania RSA w Pythonie wykorzystano bibliotekę pycryptodome oraz framework Flask dla części serwerowej. Niemniej jednak, praktyczne i bezpieczne aspekty powinny być mieć na uwadze podczas implementacji takich mechanizmów w realnych scenariuszach.
Opis zaimplementowanego algorytmu walidacji podpisu żądania
Prezentowany mechanizm bezpieczeństwa żądań został zaimplementowany zgodnie z następującymi krokami:
- Serwer sprawdza obecność wymaganych nagłówków w otrzymanym żądaniu.
- Weryfikacja formatu i unikalności wartości
X-Nonce-Value
- Serwer sprawdza, czy wartośćX-Nonce-Value
ma prawidłowy format UUID-4 i czy nie została powtórzona w poprzednich żądaniach. - Weryfikacja poprawności wartości nagłówka
X-Nonce-Created-At
- Serwer weryfikuje poprawność wartości nagłówkaX-Nonce-Created-At
. - Weryfikacja czasowej ważności nagłówka
X-Nonce-Created-At
- Serwer sprawdza, czy wartość nagłówkaX-Nonce-Created-At
jest aktualna w kontekście czasu. - Weryfikacja podpisu żądania - Serwer przechodzi do weryfikacji podpisu żądania, wykorzystując klucz publiczny, metodę, ścieżkę, treść wiadomości, wartość
X-Nonce
i wartośćX-Nonce-Created-At
. - Akceptacja lub odrzucenie żądania - Jeśli podpis jest poprawny, serwer API akceptuje żądanie i rozpoczyna jego przetwarzanie. W przypadku nieprawidłowego podpisu serwer odrzuca żądanie.
Prezentowany algorytm wprowadza kompleksową weryfikację każdego aspektu żądania, od obecności nagłówków po poprawność formatu i ważność czasową. Mechanizm ten zapewnia bezpieczne przetwarzanie tylko tych żądań, które spełniają określone kryteria, stanowiąc kluczowy element w ochronie serwera przed potencjalnymi atakami. Implementacja tego algorytmu ma na celu zapewnienie solidnej ochrony przed błędami żądań i potencjalnymi zagrożeniami bezpieczeństwa.
Implementacja
Korzystając z wcześniej przedstawionego opisu algorytmu, skupmy się teraz na etapach jego implementacji. Zacznijmy od punktu 1, który jest weryfikacją istnienia wymaganych nagłówków w otrzymanym żądaniu przez serwer. W sytuacji, gdy któryś z nagłówków nie zostanie znaleziony, funkcja zwraca nazwę brakującego nagłówka. Ta implementacja skutecznie informuje klienta o braku kluczowej części żądania. Równie ważne jest, że pozwala nam zbierać informacje diagnostyczne w logach serwera. Lista REQUIRED_HEADERS
zawiera nazwy wszystkich nagłówków wymaganych przez naszą implementację, w tym X-Nonce-Value
, X-Nonce-Created-At
i X-Signature
.
def check_headers_exists():
for header in REQUIRED_HEADERS:
if header not in request.headers:
return header
return None
Kolejny kluczowy krok w implementacji z naszej listy to punkt 2 - Weryfikacja formatu i unikalności wartości X-Nonce-Value
. W opisanym algorytmie nagłówek X-Nonce-Value
musi mieć format UUID. Dlatego przed sprawdzeniem unikalności wartości nonce, w linii numer 3, weryfikujemy poprawność formatu przy użyciu funkcji regex
.
Warto zauważyć, że często wartości nagłówków, takie jak ta używana do śledzenia żądań, powinny spełniać określone kryteria, ale rzadko są właściwie weryfikowane. Brak takiej weryfikacji może prowadzić do sytuacji, w których napastnicy wykorzystują nieautoryzowane wartości, na przykład zaciemniając logi serwera.
Na kolejnym etapie używamy biblioteki queue do przechowywania wcześniej otrzymanych wartości nonce. Jeśli nowa wartość nonce nie znajduje się w kolejce, najpierw usuwamy stare wartości nonce, a następnie dodajemy nową wartość. Użycie kolejki First-In-First-Out (FIFO) zaimplementowanej przez bibliotekę queue jest zgodne z aplikacjami wielowątkowymi, jak podkreślono w dokumentacji.
Podczas implementacji przechowywania historii nonce pojawia się istotne pytanie: ‘Ile wartości nonce powinniśmy zachować wstecz?’
def is_nonce_correct():
nonce = request.headers[NONCE_HEADER]
if re.match(UUID_PATTERN, nonce) and nonce not in received_nonces.queue:
clean_up_the_received_nonces_queue()
received_nonces.put(nonce, block=True)
return True
return False
Zależy to od charakterystyki naszej aplikacji. W kontekście opisanej przeze mnie implementacji ustalono, że wartość parametru NONCE_QUEUE_SIZE_LIMIT
jest ustawiona na 10. Gdy liczba przechowywanych nonces osiągnie lub przekroczy ten limit, system automatycznie usuwa najstarszą otrzymaną wartość, utrzymując zdefiniowaną liczbę najnowszych wartości nonce. Jest to praktyczne podejście, które pozwala na efektywne zarządzanie pamięcią i utrzymanie optymalnej wydajności systemu.
def clean_up_the_received_nonces_queue():
if received_nonces.qsize() >= NONCE_QUEUE_SIZE_LIMIT:
received_nonces.get(block=True)
Kroki 3 i 4, czyli Weryfikacja poprawności wartości nagłówka X-Nonce-Created-At
i Weryfikacja ważności czasowej nagłówka X-Nonce-Created-At
, zostały zintegrowane w jedną funkcję. Taka konsolidacja jest możliwa, ponieważ jeśli parsowanie otrzymanej wartości przy użyciu funkcji datetime.fromisoformat
nie powiedzie się, automatycznie zostanie zgłoszony wyjątek. Rozwiązanie to pozwala na jednoczesną walidację poprawności wartości nagłówka i weryfikację jego ważności czasowej, zapewniając efektywne podejście do implementacji.
W czwartym kroku, aby zweryfikować, czy żądanie jest aktualne, konieczne jest obliczenie różnicy czasu i sprawdzenie, czy mieści się ona w zdefiniowanym oknie czasowym. W opisanym przypadku wartość TIME_DIFF_TOLERANCE_IN_SECONDS
została ustawiona na 10 sekund, co jest wartością dostosowaną do celów testowych. Niemniej jednak, zalecaną wartość pozostawia się do indywidualnej decyzji użytkownika, aby dostosować ją do specyfiki swojej aplikacji i wymagań bezpieczeństwa.
def is_nonce_created_at_correct():
try:
nonce_created_at = request.headers[NONCE_CREATED_AT_HEADER]
time_diff = datetime.now().astimezone(timezone.utc) - datetime.fromisoformat(nonce_created_at)
return time_diff.total_seconds() < TIME_DIFF_TOLERANCE_IN_SECONDS
except Exception:
return False
W kroku 5 przechodzimy do kluczowej weryfikacji podpisu żądania. W tym momencie mamy pewność, że wszystkie inne nagłówki są poprawne, co pozwala nam przejść do bardziej zasobożernego działania weryfikacji podpisu. Analogicznie do podejścia stosowanego po stronie klienta, pobieramy wszystkie niezbędne znaczniki z żądania, takie jak X-Nonce-Value
, X-Nonce-Created-At
, metodę, ścieżkę i treść wiadomości. Łączymy te znaczniki przy użyciu funkcji format
, a następnie kodujemy je do formatu base64
, otrzymując wartość signature_input
. Następnie wraz z otrzymanym podpisem przekazujemy je do funkcji weryfikacji.
def is_signature_correct():
nonce_value = request.headers[NONCE_HEADER]
nonce_created_at = request.headers[NONCE_CREATED_AT_HEADER]
signature_input = "{}{}{}{}{}".format(request.method, request.path, nonce_value, nonce_created_at, request.data.decode())
signature_input_b64 = base64.standard_b64encode(signature_input.encode())
return verify(signature_input_b64, request.headers[SIGNATURE_HEADER])
Funkcja odpowiedzialna za weryfikację podpisu otrzymanego żądania przyjmuje tylko dwa argumenty. Pierwszym z nich jest signature_input_b64
, z którego obliczany jest hash przy użyciu funkcji hash SHA256, a drugim jest podpis wiadomości otrzymany od klienta. W ostatnim kroku wywoływana jest funkcja verify z biblioteki pycryptodome. W przypadku niepowodzenia weryfikacji zostanie zgłoszony wyjątek. Jeśli jednak weryfikacja zakończy się sukcesem, funkcja zwróci True, wskazując na poprawność otrzymanego podpisu wiadomości.
def verify(signature_input_b64, received_signature):
hash = SHA256.new(signature_input_b64)
try:
pkcs_signature.new(PUBLIC_KEY).verify(hash, base64.standard_b64decode(received_signature))
except (ValueError, TypeError)::
return False
return True
Ostatnim brakującym elementem w naszej implementacji jest konsolidacja wszystkich wcześniej przedstawionych funkcji i wywoływanie ich we właściwej kolejności. Postępując zgodnie z paradygmatem nie powtarzaj się (ang. don’t repeat yourself), unikamy duplikacji kodu, co oznacza, że nie chcemy, aby każda funkcja była wywoływana bezpośrednio w funkcji obsługującej określony punkt końcowy naszego serwera Flask. Aby to osiągnąć, używamy dekoratorów. Implementacja przyjmie następującą formę:
def signature_required(f):
@wraps(f)
def decorator(*args, **kwargs):
missing_header = check_headers_exists()
if missing_header:
return make_response(jsonify({"error": f"Missing header: {missing_header}"}))
if not is_nonce_created_at_correct():
return make_response(jsonify({"error": "The request is time-barred"}))
if not is_nonce_correct():
return make_response(jsonify({"error": "Nonce is already used"}))
if not is_signature_correct():
return make_response(jsonify({"error": f"Invalid Signature"}))
return f(*args, **kwargs)
return decorator
Poniżej znajduje się kod reprezentujący punkt końcowy /signed-body
, który akceptuje tylko żądania POST i wymaga weryfikacji podpisu przed przetworzeniem. Służy jako przykład użycia dekoratora @signature_required
w kontekście funkcji obsługującej określone żądanie w aplikacji Flask.
@app.route("/signed-body", methods=["POST"])
@signature_required
def signed_body():
return jsonify({"msg": "Ok!"})
Cały przedstawiony w tym artykule kod można znaleźć na moim GitHubie pod tym linkiem.
Przykład użycia
W tej sekcji artykułu zademonstruję działanie powyższego kodu. Do testowania użyłem ZAP oraz skryptu omówionego w tym artykule, który jest również dostępny w repozytorium community-scripts. Na rysunku 1 pokazano próbę wysłania żądania bez dodatkowych nagłówków. Jak widać, serwer wskazał ich brak i nie przetworzył wiadomości.
Na rysunku 2 pokazano kolejną próbę wysłania żądania, tym razem z dodanymi wymaganymi nagłówkami. Jednak zmiana w treści wiadomości spowodowała brak zgodności podpisu, co skutkowało odrzuceniem wiadomości przez serwer.
Na rysunku 3 przedstawione jest użycie opisywanego kodu. Nagłówki nonce i podpisu są poprawne, co potwierdza ważna odpowiedź serwera.
Na rysunku 4 ponownie wysłano żądanie z tymi samymi niezmienionymi wartościami X-Nonce-Value
i X-Nonce-Created-At
. Jak widać, serwer najpierw sprawdza, jak oczekiwano, czy wartość X-Nonce
nie została powtórzona, i w przypadku otrzymania tej samej wartości zgłasza błąd.
Na rysunku 5 to samo żądanie zostało wysłane ponownie, tym razem z nową wartością dla X-Nonce-Value
, ale bez aktualizacji X-Nonce-Created-At
. W odpowiedzi serwer wskazuje, że żądanie wygasło.
Podsumowanie
W tym artykule przedstawiłem praktyczny przykład implementacji walidacji podpisu żądania przy użyciu kluczy RSA po stronie serwera, wykorzystując Pythona i framework Flask. Omówiłem znaczenie podpisywania żądań w kontekście bezpieczeństwa aplikacji internetowych, ze szczególnym uwzględnieniem mechanizmu opartego na kluczach RSA.
Szczegółowo opisałem różnice między popularnym mechanizmem HMAC a podejściem opartym na kluczach RSA. Dodatkowo skupiłem się na praktycznych i bezpiecznych aspektach implementacji takich mechanizmów.
Zaprezentowałem szczegółowy opis zaimplementowanego algorytmu walidacji podpisu żądania, obejmujący kroki takie jak weryfikacja nagłówków, sprawdzanie formatu i unikalności wartości nonce, weryfikacja czasu oraz kluczowy etap weryfikacji cyfrowego podpisu żądania. Cały proces został skonsolidowany w funkcję obsługującą dekorator @signature_required
, umożliwiając efektywne i modułowe zarządzanie kodem.
Przedstawiony kod oraz opis etapów weryfikacji mogą służyć jako cenne źródło informacji dla programistów zajmujących się aspektami bezpieczeństwa aplikacji internetowych. Wierzę, że ta implementacja przyczyni się do zwiększenia świadomości i zrozumienia procesu walidacji podpisu żądania przy użyciu kluczy RSA w kontekście ochrony aplikacji internetowych.
Bibliografia
- https://en.wikipedia.org/wiki/Man-in-the-middle_attack
- https://docs.aws.amazon.com/AmazonS3/latest/API/sig-v4-authenticating-requests.html
- https://en.wikipedia.org/wiki/HMAC
- https://datatracker.ietf.org/doc/html/draft-ietf-httpbis-message-signatures
- https://en.wikipedia.org/wiki/Replay_attack
- https://www.pycryptodome.org/
- https://flask.palletsprojects.com/
- https://docs.python.org/3/library/queue.html
- https://en.wikipedia.org/wiki/FIFO_(computing_and_electronics)
- https://en.wikipedia.org/wiki/Don%27t_repeat_yourself
- https://circleci.com/blog/authentication-decorators-flask/
- https://github.com/mwalkowski/api-request-security-poc
- https://mwalkowski.com/post/using-burp-python-scripts-to-sign-requests-with-rsa-keys/
- https://mwalkowski.com/post/zed-attack-proxy-zap-scripting-and-request-signing-with-rsa-keys/