Szyfrowanie danych w bazie danych za pomocą Jasypt + Hibernate

Bezpieczeństwo danych przechowywanych w naszej aplikacji polega nie tylko na należytym zabezpieczeniu ekranu logowania oraz implementacji dobrego mechanizmu uwierzytelniania i autoryzacji użytkowników. Te dwa ostatnie zagadnienia można zrealizować chociażby przy pomocy certyfikatu SSL i usługi JAAS. Czasami wymaga się jednak, aby same dane były składowane w formie zaszyfrowanej. Najczęściej stosuje się tą metodę, jeśli dostęp do bazy danych może być uzyskany w jakikolwiek inny sposób, niż przez naszą aplikację. Odpowiednio zaimplementowany mechanizm zabezpiecza też dane w bazie przed nieautoryzowanym dostępem za pomocą potencjalnych luk w zaufanej aplikacji.

Szyfrowanie danych w bazie można zrealizować na wiele sposobów. Jednym z nich jest po prostu dopisanie do getterów i setterów naszych klas encji odpowiednich operacji szyfrowania i deszyfracji. Od stopnia zaawansowania poziomu zabezpieczenia zależy, jak duży bałagan w kodzie zrobimy i jak ciężko będzie to utrzymać. Z pomocą przychodzą biblioteki, które pozwalają na przeźroczyste szyfrowanie danych na poziomie np. Hibernate’a. Jedną z takich bibliotek jest Jasypt – narzędzie upraszczające operacje szyfrowania do niezbędnego minimum (przy czym oferujące równie zaawansowane możliwości, co API JCE – Java Cryptography Architecture). Jasypt doskonale sprawdza się w implementacjach własnych mechanizmów szyfrowania oraz integruje się z wieloma popularnymi technologiami jak Hibernate, Spring, Wicket czy JBoss Seam. Sam z siebie nie dostarcza żadnych algorytmów szyfrowania, jednak pozwala na dołączanie dodatkowych dostawców JCE umożliwiających korzystanie z bardziej zaawansowanych algorytmów jak AES czy WHIRLPOOL.

Jasypt w integracji z Hibernate’m standardowo oferuje funkcjonalność szyfrowania wszystkich, bądź wybranych danych za pomocą jednego klucza dla całej aplikacji. Czy zaszyfrujemy wszystko, czy tylko niektóre kolumny tabel, to zależy od Ciebie. Musisz przede wszystkim pamiętać, że szyfrowanie jest czasochłonne i wymaga nieco mocy obliczeniowej. Jeśli aplikacja ma być duża, a dostęp do niej będzie miało wielu użytkowników, należy zastanowić się dwa razy, zanim zaszyfrujemy całą bazę danych. Niewiele mniej bezpieczne jest szyfrowanie tylko wybranych kolumn tabel zawierających kluczowe z punktu widzenia bezpieczeństwa dane. Czy istnieje potrzeba szyfrowania kolumn z identyfikatorami, loginami użytkowników, które i tak są widoczne dla każdego np. na liście użytkowników, czy innymi danymi ogólnie dostępnymi w aplikacji? Wszystko zależy od polityki bezpieczeństwa firmy, więc odpowiedź pozostawię Tobie. Sam chcę poruszyć nieco ważniejszy wariant, który nie jest standardowo obsługiwany przez bibliotekę, a który zakłada szyfrowanie danych indywidualnymi kluczami użytkowników. Takie rozwiązanie powinno być połączone z dodatkowym filtrowaniem danych będących własnością konkretnych użytkowników, a ich zaszyfrowanie uniemożliwia przypadkowe odczytanie w przypadku niewłaściwego działania aplikacji, kiedy jakiś użytkownik dostanie nie swoje dane. Oczywiście wariant ten można zmodyfikować wprowadzając wspólne klucze dla np. grup użytkowników.

Jako punkt wyjścia do dzisiejszego projektu wybrałem projekt z implementacją JAAS oraz częściowo projekt obsługi transakcji JTA. Pierwszy stał się bazą dla nowo powstałego przykładu, z drugiego skopiowałem konfigurację pamięciowej bazy danych oraz pojedyncze klasy i fragmenty kodu pozwalające na wyświetlenie prostej listy z danymi i funkcjonalnością wstawiania nowych rekordów. Hibernate został skonfigurowany w najprostszy z możliwych sposobów, aby niepotrzebnie nie komplikować złożoności bieżącego projektu. Lista danych nie będzie filtrowana w zależności od zalogowanego użytkownika, co umożliwi zobrazowanie niedostępności poszczególnych danych. Nie będę omawiał też klas, które zostały przeniesione w całości, bądź lekko tylko zmodyfikowane, z poprzednich projektów. No to zaczynamy!

Car.hbm.xml
<?xml version="1.0"?>
<!DOCTYPE hibernate-mapping PUBLIC
    "-//Hibernate/Hibernate Mapping DTD 3.0//EN"
    "http://hibernate.org/dtd/hibernate-mapping-3.0.dtd">

<hibernate-mapping>

  <typedef name="encryptedString" class="org.jasypt.hibernate.type.EncryptedStringType">
    <param name="encryptorRegisteredName">sessionStringEncryptor</param>
  </typedef>

  <class name="pl.avd.samples.gwt.jasypt.shared.Car" table="T_CARS">
    <id name="id" column="ID">
      <generator class="increment"/>
    </id>

    <property name="brand" column="BRAND" type="encryptedString"/>
    <property name="model" column="MODEL" type="encryptedString"/>
    <property name="color" column="COLOR" type="encryptedString"/>
  </class>
</hibernate-mapping>

Jasypt dostarcza implementacje własnych typów danych (UserType) dla Hibernate’a. Nowe typy danych umożliwiają wskazanie odpowiedniego szyfratora danych, który zarejestrujemy później w aplikacji.

ApplicationListener.java
public class ApplicationListener implements ServletContextListener {

  private static Logger LOG = LoggerFactory.getLogger(ApplicationListener.class);

  public void contextInitialized(ServletContextEvent e) {
    LOG.info("context initialized");
    PBEStringEncryptor encryptor = new SessionPBEStringEncryptor();

    HibernatePBEEncryptorRegistry registry = HibernatePBEEncryptorRegistry.getInstance();
    registry.registerPBEStringEncryptor("sessionStringEncryptor", encryptor);
  }

  public void contextDestroyed(ServletContextEvent e) {
    LOG.info("context destroyed");
  }
}

Jako że stworzony projekt jest aplikacją webową, można skorzystać z funkcjonalności listenera ServletContext’u. Odpowiednie metody listenera będą uruchamiane wraz ze startem i zakończeniem działania aplikacji na serwerze. Moment startu aplikacji jest więc doskonałym miejscem na zarejestrowanie naszego szyfratora danych w rejestrze szyfratorów. Szyfrator rejestrowany jest pod nazwą wskazaną wcześniej w mapowaniu klasy Car.

ThreadPrincipalStorage.java
public class ThreadPrincipalStorage {

  private static ThreadPrincipalStorage instance = new ThreadPrincipalStorage();

  private Map<Long, Principal> map;

  public static void registerThreadPrincipal(long threadId, Principal principal) {
    get().map.put(threadId, principal);
  }

  public static void unregisterThreadPrincipal(long threadId) {
    get().map.remove(threadId);
  }

  public static Principal getThreadPrincipal(long threadId) {
    return get().map.get(threadId);
  }

  private static ThreadPrincipalStorage get() {
    return instance;
  }

  private ThreadPrincipalStorage() {
    map = new HashMap<Long, Principal>();
  }

}

Potrzebna jest nam mała składnica danych, która będzie przechowywała dane zalogowanego użytkownika w aktualnym wątku. Jako że pojedyncze żądanie jest obsługiwane przez osobny wątek, który w danym momencie nie może być użyty do obsługi innego żądania, można tu przypisywać identyfikatorom wątków pojedyncze wartości z danymi użytkowników. Składnica będzie tu przechowywała obiekty klasy java.security.Principal, które zawierają nazwę zalogowanego użytkownika. Jest to najprostsza implementacja takiej składnicy, która w zupełności wystarczy na potrzeby bieżącego projektu.

TransactionFilter.java
public class TransactionFilter implements Filter {

  private Logger LOG = LoggerFactory.getLogger(TransactionFilter.class);

  public void destroy() {
  }

  public void init(FilterConfig filterConfig) throws ServletException {
  }

  public void doFilter(ServletRequest req, ServletResponse resp,
      FilterChain chain) throws IOException, ServletException {

    if (req instanceof HttpServletRequest) {
      long threadId = Thread.currentThread().getId();
      Principal principal = ((HttpServletRequest) req).getUserPrincipal();

      ThreadPrincipalStorage.registerThreadPrincipal(threadId, principal);

      Transaction tx = null;

      try {
        tx = HibernateUtil.getCurrentSession().getTransaction();
        tx.begin();

        chain.doFilter(req, resp);

        // Commit and cleanup
        LOG.trace("Committing the database transaction");

        tx.commit();
      } catch (StaleObjectStateException staleEx) {
        LOG.error("This interceptor does not implement optimistic concurrency control!");
        LOG.error("Your application will not work until you add compensation actions!");
        // Rollback, close everything, possibly compensate for any permanent changes
        // during the conversation, and finally restart business conversation. Maybe
        // give the user of the application a chance to merge some of his work with
        // fresh data... what you do here depends on your applications design.
        throw staleEx;
      } catch (Throwable ex) {
        // Rollback only
        LOG.error("Exception thrown during transaction: " + ex);

        try {
          LOG.info("Trying to rollback database transaction after exception");
          tx.rollback();
        } catch (Throwable rbEx) {
          LOG.error("Could not rollback transaction after exception!" + rbEx.getMessage());
        }

        // Let others handle it... maybe another interceptor for exceptions?
        throw new ServletException(ex);
      } finally {
      }

      ThreadPrincipalStorage.unregisterThreadPrincipal(threadId);
    } else {
      chain.doFilter(req, resp);
    }
  }
}

Zmodyfikowany nieco filtr transakcji z projektu o transakcjach JTA obsługuje teraz zwykłe transakcje JDBC za pomocą API Hibernate’a. Dodałem tutaj też kod rejestrujący zalogowanego użytkownika w utworzonej wcześniej składnicy i wyrejestrowujący go po zakończeniu obsługi serwletu. Dzięki temu w trakcie obsługi żądania można uzyskać informacje o aktualnym użytkowniku w danym wątku.

SessionPBEStringEncryptor.java
public class SessionPBEStringEncryptor implements PBEStringEncryptor {

  public void setPassword(String password) {
  }

  public String encrypt(String message) {
    PBEStringEncryptor encryptor = new StandardPBEStringEncryptor();
    encryptor.setPassword(getPassword());
    return encryptor.encrypt(message);
  }

  public String decrypt(String encryptedMessage) {
    PBEStringEncryptor encryptor = new StandardPBEStringEncryptor();
    encryptor.setPassword(getPassword());

    try {
      return encryptor.decrypt(encryptedMessage);
    } catch (EncryptionOperationNotPossibleException e) {
      return "<< encrypted >>";
    }
  }

  private String getPassword() {
    Principal principal = ThreadPrincipalStorage.getThreadPrincipal(Thread.currentThread().getId());

    if (principal != null) {
      return principal.getName();
    } else {
      return null;
    }
  }
}

Tworzymy własną implementację szyfratora opartego o sesję użytkownika. Podczas każdej operacji szyfrowania i deszyfrowania tworzony jest standardowy szyfrator, do którego przypisywane jest następnie aktualne hasło. Jako hasła użyłem nazwy zalogowanego użytkownika, choć nic nie stoi na przeszkodzie, aby na jej podstawie wybrać indywidualne hasło przechowywane np. w bazie danych. Powyższa implementacja jest również mało doskonała z tego względu, że dla każdej operacji tworzone są nowe szyfratory. Można więc tu wprowadzić drobne udoskonalenia, jak chociażby przechowywanie szyfratorów dla konkretnych użytkowników w pamięci podręcznej i ponowne ich wykorzystywanie.
W przypadku niepowodzenia podczas deszyfracji Jasypt rzuca wyjątkiem EncryptionOperationNotPossibleException. Można ten wyjątek przechwycić i zwrócić rozsądny komunikat o zaszyfrowanej treści.

Projekt można uruchomić poleceniem mvn gwt:run. Podstawowa konfiguracja JAAS’a umożliwia zalogowanie się dwóm użytkownikom: test1/pass1 i test2/pass2. W celu przetestowania projektu zalogowałem się kolejno na tych użytkowników. Jako test1 dodałem dwa nowe rekordy do bazy danych, po czym przelogowałem się na użytkownika test2 i dodałem jeszcze jeden rekord. Oto jak wygląda teraz lista danych po zalogowaniu się na poszczególnych użytkowników:

W celu upewnienia się, że dane zostały faktycznie zaszyfrowane, otworzyłem plik logów pamięciowej bazy danych, aby zobaczyć, jakie dane były wstawiane do tabeli:

INSERT INTO T_CARS VALUES(1,'bu9ZlrQBCAtwUElNBfaGImc7R+odVlm4','gCfE8wL4GoHFIBsMpe6fAQ==','gCfE8wL4GoE0oNkLCaVp1Z7k3Q7e+FT9')
COMMIT
INSERT INTO T_CARS VALUES(2,'Gw/QpHU5xxe8UuX8Y2ZsWiAYjYkErJnF','uexNMjyAKQ6DCr8TGP0MGg==','uexNMjyAKQ4sulJeEwQwiw==')
COMMIT
INSERT INTO T_CARS VALUES(3,'D0ed4z9PLf2n24g4BCXsDw==','D0ed4z9PLf0cy1lKkBusjQ==','az9MHDhqys5wMnNE3Av45A==')
COMMIT

Po powyższych logach można z pewnością stwierdzić, że dane są całkowicie bezpieczne przed niepowołanym dostępem, a odczytać je można jedynie z poziomu odpowiednio skonfigurowanej aplikacji.

Przedstawiony projekt został umieszczony na repozytorium i można go pobrać stąd. Więcej informacji o bibliotece Jasypt można uzyskać tutaj, a opis podstawowej konfiguracji Jasypt z Hibernate’m można zobaczyć tutaj.

Podczas projektowania właściwej bazy danych należy pamiętać, że zaszyfrowane dane mają postać o wiele dłuższą, niż ich odszyfrowane odpowiedniki. Ciekawy wpis na temat Jasypt, w którym znalazł się fragment o wyliczaniu długości zaszyfrowanych kolumn, znajduje się na blogu ISolution.

Skomentuj


UWAGA - Możesz używać HTML tags and attributes:
<a href="" title=""> <abbr title=""> <acronym title=""> <b> <blockquote cite=""> <cite> <code> <del datetime=""> <em> <i> <q cite=""> <strike> <strong>

CommentLuv badge