sobota, 3 marca 2012

"Legacy code" a możliwości C++

Być może niektórzy czytelnicy mojego bloga nieco "przestraszyli się" poprzednimi wpisami. Dzisiaj będzie coś łatwiejszego i mam nadzieję, że wiele osób będzie mogło wykorzystać w swoich projektach informacje zawarte w niniejszym wpisie już dziś.

W większych projektach bardzo często sięga się do bibliotek stworzonych przez osoby postronne. Ma to wiele zalet: oszczędzamy w ten sposób czas, spodziewamy się, że funkcjonalność zawarta w bibliotece jest napisana poprawnie, a wykorzystane algorytmy są optymalne itp. Jednakże często bywa, że znajdujemy świetnie nadającą się do naszego projektu bibliotekę, ale napisaną np. w języku C. Przykładowo funkcje tej biblioteki alokują pamięć na stercie, a dołączona dokumentacja informuje nas, że to my (jako użytkownicy biblioteki) powinniśmy pamiętać o jej zwolnieniu (za pomocą odpowiednich funkcji wchodzących w skład biblioteki). Dodatkowo używane funkcje mogą zakończyć się niepowodzeniem, co powinniśmy wykrywać za pomocą zwracanych przez nie wartości, bądź pilnować jakiejś globalnej flagi. Krótko mówiąc: typowa biblioteka napisana w języku C. A my lubimy C++... ;)

Zaprezentuję dziś na przykładzie, jak można radzić sobie przypadkami takimi jak opisany powyżej. Zapraszam do lektury.

Na potrzeby pracy magisterskiej szukałem biblioteki, która dostarczyłaby mi funkcjonalność haszowania i szyfrowania tekstu, zarządzania kluczami kryptograficznymi itp. Wybór padł na Libgcrypt. Szybka i sprawdzona biblioteka, jednak napisana własnie w C.

Zaczniemy od analizy "interfejsu" udostępnianego przez bibliotekę Libgcrypt. W naszym przykładzie ograniczymy się jedynie do funkcji haszujących. Pełna dokumentacja znajduje się tutaj.

  1. Biblioteka udostępnia wiele algorytmów haszujących np. GCRY_MD_MD5, GCRY_MD_SHA1 czy GCRY_MD_SHA256. Są to wszystko wartości enum, po których funkcje haszujące rozpoznajają jakiego algorytmu użyć.
  2. Funkcje realizujące algorytmy haszowania pobierają też flagi, którymi można sterować ich zachowaniem. Dostępne flagi to: GCRY_MD_FLAG_SECURE (optymalizuje pamięć pod kątem bezpieczeństwa) oraz GCRY_MD_FLAG_HMAC (dodaje kod MAC z wmieszanym tajnym kluczem - wiecej o HMAC tutaj). Znowu są to jakieś enum'y, które musimy za każdym razem przekazywać jako parametr wywołania funkcji.
  3. Żeby móc haszować tekst potrzebujemy czegoś w rodzaju handlera, a precyzyjniej zmiennej typu gcry_md_hd_t.
  4. Wreszcie możemy przejść do konkretów. Żeby móc haszować tekst trzeba wywołać funkcję: gcry_error_t gcry_md_open(gcry_md_hd_t* hd, int algo, unsigned int flags), gdzie:
    • gcry_error_t - typ zwracany przez funkcje oznaczający kod błędu,
    • hd - wspomniany wcześniej handler,
    • algo - rodzaj algorytmu jaki chcielibyśmy użyc do haszowania,
    • flags - wspomniane wcześniej flagi.
    Niezbyt przyjemny interfejs. Zwłaszcza, że ta funkcja nie robi z naszego punktu widzenia nic. Po prostu dokumentacja każe nam ją wywołać zanim zaczniemy działać... Dodatkowo funkcja to alokuje jakieś zasoby, które później sami musimy zwolnić.
  5. Do zwalniania zasobów należy użyć funkcji: void gcry_md_close(gcry_md_hd_t h), gdzie:
    • h - użyty w funkcji gcry_md_open handler.
  6. Jeżeli zdecydowaliśmy się skorzystać z HMAC możemy ustawić klucz za pomocą funkcji: gcry_error_t gcry_md_setkey(gcry_md_hd_t h, const void* key, size_t keylen), gdzie:
    • gcry_error_t - typ zwracany przez funkcje oznaczający kod błędu,
    • h - nasz handler,
    • key - początek obszaru pamięci zawierającego bajty składające się na nasz klucz,
    • keylen - długość klucza (wyrażona jako ilość bajtów).
  7. Aby ustawić tekst do haszowania należy wywołać funkcję: void gcry_md_write(gcry_md_hd_t h, const void* buffer, size_t length), gdzie:
    • h - nasz handler,
    • buffer - ponownie ciąg bajtów zawierający nasz tekst,
    • length - długość tekstu wyrażona w bajtach.
  8. Aby uzyskać zahaszowany tekst należy wywołać funkcję:
    unsigned char* gcry_md_read(gcry_md_hd_t h, int algo), gdzie:
    • zwracany typ wskazuje na adres pamięci, pod którym znajduje się zahaszowany tekst,
    • h - nasz handler,
    • algo - wartość mówiąca funkcji, jakiego algorytmu ma użyć.
  9. Żeby nie musieć pamiętać jakiej długości hasze generują odpowiednie algorytmy, możemy użyć następującej funkcji, która nam to powie: gcry_md_get_algo_dlen(int algo), o ile przekażemy jej ten sam argumen algo, co do funkcji gcry_md_read. ;)

Mniej więcej taki wycinek z dokumentacji powinien nam wystarczyć, aby zahaszować jakiś tekst. Spróbujmy. Zahaszowanie tekstu "Ala ma kota" z użyciem algorytmu SHA256 mogłoby wyglądać na przykład tak:

gcry_md_hd_t handler;  // nasz handler

// to musimy wywołać:
gcry_md_open(
    &handler,
    GCRY_MD_SHA256,
    GCRY_MD_FLAG_SECURE);

gcry_md_setkey(
    handler,
    "super_tajne_haslo",
    std::strlen("super_tajne_haslo"));

gcry_md_write(
    handler,
    "Ala ma kota",
    std::strlen("Ala ma kota"));

unsigned char* hash = gcry_md_read(handler, GCRY_MD_SHA256);
unsigned hashLength = gcry_md_get_algo_dlen(GCRY_MD_SHA256);

// zrób coś...
foo(hash, hashLength);

// ...i nie zapomnij o:
gcry_md_close(handler);

Okej... Udało nam się zahaszować tekst "Ala ma kota". Czy na pewno? A co jeśli, któraś z funkcji zakończyła się niepowodzeniem? Szybki rzut oka na dokumentację i wiemy, że zawieść mogły funkcje gcry_md_open oraz gcry_md_setkey. Powinniśmy zatem sprawdzać wartości przez nie zwracane (pogrubioną czcionką zaznaczyłem zmiany w kodzie):

gcry_md_hd_t handler;  // nasz handler
gcry_error_t error;    // kod błędu

// to musimy wywołać:
error = gcry_md_open(
    &handler,
    GCRY_MD_SHA256,
    GCRY_MD_FLAG_SECURE);
if(error) {
  // obsługa błędu...
}

error = gcry_md_setkey(
    handler,
    "super_tajne_haslo",
    std::strlen("super_tajne_haslo"));
if(error) {
  // obsługa błędu...
}

gcry_md_write(
    handler,
    "Ala ma kota",
    std::strlen("Ala ma kota"));

unsigned char* hash = gcry_md_read(handler, GCRY_MD_SHA256);
unsigned hashLength = gcry_md_get_algo_dlen(GCRY_MD_SHA256);

// zrób coś...
foo(hash, hashLength);

// ...i nie zapomnij o:
gcry_md_close(handler);

No cóż... Kod się rozrasta, ale wygląda na to, że teraz powinniśmy wykryć błędy, które mogą się pojawić podczas uruchomienia programu. Czy wszystko jest już w porządku?

Funkcja foo wykonuje jakieś operacje na zahaszowanym tekście - na przykład zapisuje go do pliku lub wysyła przez sieć Internet. Co jeżeli funkcja niespodziewanie zakończy działanie programu? A może rzuci jakimś wyjątkiem, który nie zostanie wewnątrz niej złapany? Spowoduje to, że nie zostanie wywołana funkcja gcry_md_close i zasoby przydzielone przez bibliotekę Libgcrypt nie zostaną zwolnione. Proste używanie tej biblioteki zaczyna prowadzić do niezrozumiałego, ciężkiego w utrzymaniu i podatnego na błędy kodu.

W dalszej części wpisu pokażę jak wykorzystać możliwości języka C++, aby móc w pełni bezpiecznie i w znacznie prostszy sposób korzystać z wyżej opisanej funkcjonalności.

Przede wszystkim za haszowanie dowolnych tekstów konkretnym algorytmem odpowiadać będzie konkretna klasa. Zaczniemy jednak od zdefiowania dla tych klas wspólnego interfejsu. Pozwoli nam to określić, jakie wymagania powinny one spełniać, aby mogły być wygodnie używane. W idealnym przypadku chcielibyśmy móc:

  1. utworzyć obiekt realizujący dany algorytm szyfrowania,
  2. przekazać mu kod MAC,
  3. przekazać mu tekst do zahaszowania,
  4. wyciągnąć wartość funkcji haszującej (zahaszowany tekst).
Cała reszta powinna dziać się automatycznie i "być gdzieś ukryta". Interfejs ten mógłby wyglądać na przykład tak:

class IHashFunction {
public:
  virtual ~IHashFunction() {}

  virtual void setKey(const std::string& key) = 0;
  virtual void setText(const std::string& text) = 0;
  virtual unsigned int getHashLength() = 0;
  virtual unsigned char* getHash() = 0;
  virtual std::string getHexHash() = 0;
};

Dodatkowo dodałem metodę getHexHash, która łańcuch znaków zawierający heksadecymalną reprezentację zahaszowanego tekstu. Pozwoli nam to później w łatwy sposób zweryfikować poprawność działania klasy. Interfejs jest raczej intuicyjny, więc pozwolę sobie pominąć jego opis. ;)

Wszystkie nasze klasy implementujące algorytmy haszujące (jedna klasa dla jednego algorytmu - single responsibility principle) będą dziedziczyć po powyższej klasie IHashFunction. Zwróćmy uwagę, że biblioteka Libgcrypt rozpoznaje rodzaj algorytmu po wartości parametru jaki przekazujemy np. do funkcji gcry_md_open. Wykorzystamy to i stworzymy na raz wszystkie klasy wykorzystując szablony języka C++:

template<gcry_md_algos Algo, gcry_md_flags Flags>
class HashFunction : public IHashFunction {
  // ...
};

Jak widać poprzez szablonowy parametr możliwe jest również określenie flag, które zostaną przekazane funkcjom biblioteki Libgcrypt podczas haszowania. Daje nam to możliwość dowolnych kombinacji przy specjalizowaniu klasy HashFunction.

W implementacji klasy HashFunction wykorzystamy technikę RAII stworzoną przez Bjarne Stroustrupa. Mówi ona w skrócie tyle: pozyskiwanie zasobów powinno następować przy inicjowaniu obiektu, a ich zwalnianie wraz z usuwaniem tego obiektu. Doskonale rolę tę spełnią konstruktor i destruktor klasy HashFunction.

Po krótkim wstępie zobaczmy, jak mogłaby wyglądać implementacja klasy HashFunction:

template<gcry_md_algos Algo, gcry_md_flags Flags>
class HashFunction : public IHashFunction {
public:
  HashFunction() {
    gcry_error_t error = gcry_md_open(&handler, Algo, Flags);
    if(error) throw std::runtime_error("Libgcrypt error!");
  }

  ~HashFunction() {
    gcry_md_close(handler);
  }

  void setKey(const std::string& key) {
    gcry_error_t error = gcry_md_setkey(handler, key.c_str(), key.size());
    if(error) throw std::runtime_error("Libgcrypt error!");
  }

  void setText(const std::string& text) {
    gcry_md_write(handler, text.c_str(), text.size());
  }

  unsigned int getHashLength() {
    return gcry_md_get_algo_dlen(Algo);
  }

  unsigned char* getHash() {
    return gcry_md_read(handler, Algo);
  }

  std::string getHexHash() {
    std::ostringstream oss;
    unsigned int hashLength = getHashLength();
    unsigned char* hash = getHash();
    oss << std::setfill('0');
    for(unsigned int i = 0; i < hashLength; ++i)
      oss << std::setw(2) << std::hex << static_cast(hash[i]);
    return oss.str();
  }

private:
  gcry_md_hd_t handler;
};

Ukrywamy handler gcry_md_hd_t oraz wszystkie niewygodne funkcje biblioteki Libgcrypt we wnętrzu klasy HashFunction. Ponadto zamieniamy niewygodną obsługę błędów za pomocą wartości zwracanych przez funkcje na wyjątki języka C++. Konstruktor zapewnia nam poprawną inicjalizację zmiennej handler, a zasoby które zostały jej przydzielone zwalnia destruktor.

Warto zrobić jeszcze jedną rzecz. Wyspecjalizujmy sobie klase HashFunction dla algorytmu, który chcielibyśmy używać w naszym programie. Na przykład:

typedef HashFunction<GCRY_MD_SHA256, GCRY_MD_FLAG_SECURE> SHA256;

Wszystko jest gotowe. Sposób w jaki możemy teraz haszować dowolne teksty staje się zupełnie prosty, czytelniejszy i o wiele mniej podatny na błędy:

try {
  HMAC_SHA256 hasher;
  hasher.setKey("super_tajne_haslo");
  hasher.setText("Ala ma kota");
  foo(hasher.getHash(), hasher.getHashLength());
}
catch{std::exception& e} {
  // obsługa wyjątku...
}

Zastosowanie wyjątków C++ pozwala nam obsłużyć nieprzewidziane zachowanie programu w sposób czytelny i w jednym konkretnym miejscu. Dzięki zastosowaniu techniki RAII, nie musimy się w ogóle martwić o zwalnianie jakichkolwiek zasobów przydzielanych przez bibliotekę Libgcrypt. Wszystkim zajmie się kompilator. Każde wyjście poza zakres istnienia zmiennej hasher (w tym zakończenie działania programu przez funkcję foo lub wyjątki jakie mogłaby ona rzucić) spowoduje automatyczne wywołanie jej destruktora.

Dzięki opakowaniu funkcji biblioteki Libgcrypt w klasę tę samę funkcjonalność co na początku wpisu uzyskaliśmy w zaledwie kilku linijkach kodu. W dodatku jest on bezpieczniejszy, czytelniejszy i łatwiejszy w utrzymaniu.

Na koniec jeszcze kilka słów o metodzie getHexHash pozwalającej na łatwą weryfikację poprawności działania klasy. Przekształca ona bity zahaszowanego tekstu na drukowalny łańcuch znaków:

void printHash(
    const std::string& hashFunctionName,
    const std::string& text,
    const std::string& hash) {
  std::cout <<  hashFunctionName << "(\"" << text << "\") = " << hash << std::endl;
}

int main() {
  const std::string text = "Ala ma kota";

  HMAC_SHA256 hasher;
  hasher.setKey("super_tajne_haslo");
  hasher.setText(text);
  printHash("HMAC_SHA256", text, hasher.getHexHash());

  typedef HashFunction<GCRY_MD_MD5, GCRY_MD_FLAG_SECURE> MD5;
  MD5 md5Hasher;
  md5Hasher.setText(text);
  printHash("MD5", text, md5Hasher.getHexHash());
}

Oczekiwany output:

HMAC_SHA256("Ala ma kota") = 82e49039e24932cd3524d4668a673dd089d2a8e407fb523e4c452a267bb6e3df
MD5("Ala ma kota") = 91162629d258a876ee994e9233b2ad87

Pliki do pobrania:

3 komentarze:

  1. Mija juz miesiac od ostatniego wpisu, a tutaj nadal nie widac nowego :S Bardzo smutna to sytuacji. Wiele oczu skierowanych jest na Twoj blog w oczekiwaniu na kolejny wpis - lepsze jutro... Dlatego oczekujemy tego lepszego, jutro ;)

    --Waldek

    OdpowiedzUsuń