Szyfrowanie danych w Androidzie

Informacja jest droższa niż złoto w dzisiejszych czasach. Wyobraź sobie aplikację, w której użytkownik pobiera informacje z usługi chmurowej. Aby otrzymać dane użytkownik musi posiadać kod, na podstawie którego serwer będzie go weryfikował. Aby nikt nie przechwycił tokenu, aplikacja musi w bezpieczny sposób przechować klucz autoryzacyjny. Z tego powodu szyfrowanie i deszyfrowanie musi odbywać się po stronie klienta. W jaki sposób to zrobić? Dowiesz się tego z tego materiału.

Podstawowe pojęcia

Aby zrozumieć jak działa szyfrowanie, musimy wyjaśnić sobie kilka podstawowe pojęcia.

Techniki szyfrowania informacji

Wyróżniamy dwa rodzaje algorytmów kryptograficznych z kluczem:

  • Symetryczne
  • Asymetryczne

Szyfrowanie symetryczne

Szyfrowanie symetryczne – najstarsza i najbardziej znana technika. Korzystamy z tego samego klucza do szyfrowania i deszyfrowania danych. SecretKey (tajny klucz) może być liczbą, słowem lub po prostu ciągiem losowych znaków. Za jego pomocą szyfrujemy oraz deszyfrujemy tekst. Najbardziej popularnym algorytmem szyfrowania jest AES.

Szyfrowanie symetryczne dzielą się na szyfry strumieniowe i blokowe:

  • Strumień szyfru – algorytm symetryczny, który szyfruje oddzielnie każdy bit wiadomości jednocześnie z kluczem, co powoduje losowe dane szyfrowe.
  • Blok szyfrowy – algorytm działający na grupach bitów o stałej długości, zwanych blokami. Polega na szyfrowaniu bloku wejściowego (np. fragmentu pliku) na podstawie zadanego klucza, przekształcając go na blok wyjściowy o takiej samej długości w taki sposób, że niemożliwe jest odwrócenie tego przekształcenia bez posiadania klucza.

Szyfrowanie asymetryczne

Szyfrowanie asymetryczne – w tej technice istnieją dwa klucze powiązane ze sobą. Jest to para kluczy. Klucz publiczny jest znany powszechnie i może być używany przez wszystkich zainteresowanych do szyfrowania informacji. Natomiast posiadacz drugiego klucza z pary – klucza prywatnego (który nie jest publicznie znany), może rozszyfrować takie dane. Podobnie, wiadomość zaszyfrowana za pomocą klucza prywatnego, mogą być odszyfrowane tylko przy użyciu odpowiadającego mu klucza publicznego. Najbardziej popularnym algorytmem szyfrowania z kluczem publicznym jest RSA.

Oba typy szyfrowania mają zarówno zalety, jak i wady względem siebie. Algorytmy szyfrowania symetrycznego w porównaniu do asymetrycznego są znacznie szybsze i wymagają mniejszej mocy obliczeniowej, ale ich główną słabością jest właściwa i bezpieczna dystrybucja kluczy. Ponieważ ten sam klucz służy do szyfrowania i deszyfrowania informacji. W szyfrowaniu asymetrycznym nie musisz się martwić o przekazywanie kluczy publicznych w Internecie. Z drugiej strony, z racji tego, że napastnik może samodzielnie szyfrować za pomocą powszechnie dostępnego klucza publicznego dowolne wiadomości, szyfry asymetryczne są bardziej narażone na ataki. Dlatego można się spotkać z sytuacją, gdzie szyfrowania z kluczem publicznym jest wykorzystywane do nawiązania bezpiecznego połączenia, a następnie przekazania nowego sekretnego klucza, który będzie używany do szyfrowania symetrycznego, chroniącego całą dalszą komunikację.

Tryby i wypełnienia

Podczas szyfrowania wielu bloków danych przy użyciu szyfru blokowego można zastosować różne tryby szyfrowania, z których każdy ma zalety i wady.

  • Tryb ECB jest to najprostszy tryb, w którym każdy blok danych jest szyfrowany za pomocą tego samego klucza. Jednak ten tryb powoduje wyciek informacji o tekście jawnym i dlatego jest rzadko używany. Jeśli dwa razy tym samym kluczem zaszyfrujemy identyczne informacje to otrzymamy dwa identyczne szyfrogramy.
  • Tryb CBC —  próbuje ulepszyć EBC, uzależniając szyfrowanie każdego bloku nie tylko od klucza, ale także od zaszyfrowanego tekstu poprzedniego bloku. Wadą jest to, że każdy błąd może przenosić się na kolejny blok. Aby każda wiadomość była unikalna, w pierwszym bloku należy użyć wektora inicjującego.
  • Tryb CTR – zamienia szyfr blokowy w szyfr strumieniowy, co oznacza, że ​​nie jest wymagane wypełnianie. W swojej podstawowej formie wszystkie bloki są ponumerowane od 0 do n. Każdy blok będzie teraz szyfrowany kluczem, IV i wartością licznika. Zaletą jest to, że w przeciwieństwie do CBC, szyfrowanie może odbywać się równolegle. Nigdy nie wolno ponownie używać wektora inicjującego z tym samym kluczem, ponieważ atakujący może w prosty sposób obliczyć z niego użyty klucz.

Opracowano wiele innych trybów dla określonych przypadków użycia, na przykład LRW, XEX, CMC, EME i XTS do szyfrowania dysku. Każdy ma swoje zalety i wady pod względem bezpieczeństwa, użyteczności i wydajności.

Szyfr blokowy działa na jednostkach o stałym rozmiarze (znanym jako rozmiar bloku), ale wiadomości mają różną długość. Związku z tym tekst zwykły może być wypełniony na wiele różnych sposobów. Najpopularniejszym wypełnianie jest PKCS#5 lub PKCS#7. Dlatego niektóre tryby (mianowicie EBC i CBC) wymagają, aby ostatni blok był wypełniony przed szyfrowaniem.

Wektor inicjalizacji – aby losowo zaszyfrować tekst pierwszego bloku, a tym samym uczynić każdy tekst zaszyfrowany unikalnym, używany jest „wektor inicjujący” (IV). IV jest liczbą losową znaną zarówno osobom szyfrującym, jak i osobom odszyfrowującym i powinna być używana tylko raz. Nie jest wymagane jego chronienie. Zazwyczaj jest dołączony do tekstu zaszyfrowanego.

Architektura kryptografii w Androidzie

Android opiera się na architekturze Java Cryptography Architecture (JCA), która zapewnia interfejs API do podpisów cyfrowych, certyfikatów, szyfrowania, generowania kluczy i zarządzania nimi.

JCA opiera się na niektórych klasach i interfejsach ogólnego przeznaczenia. Prawdziwą funkcjonalność tych interfejsów zapewniają dostawcy. Dlatego możesz użyć klasy Cipher do szyfrowania i deszyfrowania danych, ale konkretna implementacja szyfru (algorytm szyfrowania) zależy od konkretnego używanego dostawcy.

Dostawcy to grupy różnych algorytmów. Klasa Provider to centralna klasa interfejsu API kryptografii. Java posiada własnego dostawcę kryptografii. Jeśli nie ustawisz jawnego dostawcy kryptografii, zostanie użyty domyślny. Ten dostawca może jednak nie obsługiwać algorytmów szyfrowania, których chcesz użyć. Dlatego może być konieczne ustawienie własnego dostawcy kryptografii.

info

Możesz implementować i podłączać również własnych dostawców, ale powinieneś być ostrożny. Prawidłowe wdrożenie szyfrowania bez luk bezpieczeństwa jest bardzo trudne!

Klasa KeyGenerator – zapewnia interfejs API do generowania symetrycznych kluczy kryptograficznych. Do generowania kluczy asymetrycznych służy klasa KeyPairGenerator. Jest w stanie wygenerować klucz prywatny i powiązany z nim klucz publiczny przy użyciu algorytmu, z którego został zainicjowany.

KeyStore – baza danych z dobrze zabezpieczonym mechanizmem ochrony danych, służącym do zapisywania, pobierania i usuwania kluczy. Wymaga hasła wejściowego dla każdego klucza. Innymi słowy jest to plik chroniony, który należy utworzyć, odczytać i zaktualizować przy pomocy dostarczonego API.

 Możesz wyświetlić listę dostępnych dostawców w Androidzie w następujący sposób:

Security.getProviders().forEach{
    Log.d("TAG", it.toString())
}

W emulatorze Android Pixel 2 API 28 ten kod generuje:

Keystore: KeyStore.BouncyCastle available in provider: BC
Keystore: KeyStore.PKCS12 available in provider: BC
Keystore: KeyStore.BKS available in provider: BC
Keystore: KeyStore.AndroidCAStore available in provider: HarmonyJSSE
Keystore: KeyStore.AndroidKeyStore available in provider: AndroidKeyStore

AndroidKeyStore

AndroidKeyStore to system, który umożliwia programistom tworzenie i przechowywanie kluczy kryptograficznych w pojemniku czyniąc je bardziej trudne do wyodrębnienia z urządzenia. Te klucze są przechowywane w specjalistycznym sprzęcie, tak zwanym zaufanym środowisku wykonawczym. Został wprowadzony w API 18 (Android 4.3). Magazyn kluczy Androida wspierany przez Strongbox jest obecnie najbezpieczniejszym i zalecanym typem magazynu kluczy. Gdy klucze znajdą się w magazynie kluczy, można ich używać do operacji kryptograficznych, ale nie można ich eksportować

AndroidKeyStore to implementacja JCA Provider, w której:

  • Nie są wymagane hasła do KeyStore
  • Operacje kryptograficzne nigdy nie wchodzi w proces aplikacji
  • Jeśli urządzenie obsługuje środowisko Trusted Execution Environment (TEE) lub Secure Element (SE), klucze zostaną tam zapisane. W przeciwnym wypadku klucze będą przechowywane w emulowanym środowisku programowym dostarczonym przez system.
  • Klucze asymetryczne są dostępne od API 18+
  • Klucze symetryczne są dostępne od API 23+

Ważną informacją jest to, że klucze zostaną automatycznie usunięte z systemu po usunięciu aplikacji. Również klucze nie są ujawniane nawet tobie. Będziemy pracować tylko z referencjami, które są przekazywane do usługi systemowej KeyStore, gdzie jest wykonywana cała praca.

Typy magazynu kluczy

Android obsługuje 7 różnych typów mechanizmów magazynu kluczy, z których każdy ma swoje zalety i wady. Na przykład AndroidKeystore używa chipa sprzętowego do przechowywania kluczy w bezpieczny sposób, podczas gdy Bouncy Castle Keystore (BKS) jest magazynem kluczy programowych i wykorzystuje zaszyfrowany plik umieszczony w systemie plików.

Bardziej aktualną listę można znaleźć w tym artykule.

Istnieją inne typy magazynów kluczy, na przykład Samsung obsługuje własny typ magazynu kluczy o nazwie TIMA.

Magazyn kluczy wspierany sprzętowo

Aby dodatkowo podnieść bezpieczeństwo kluczy w Android Marshmallow (API 23) zaimplementowano magazyn kluczy wspierany sprzętowo. Poprzez KeyInfo.isInsideSecureHardware() można sprawdzić, czy urządzenie przechowuje klucze, korzystając z czystej implementacji oprogramowania czy z magazynu kluczy zabezpieczonym sprzętowo.

Trusted Execution Environment (TEE)

Zaufane środowisko wykonawcze (TEE) – jest implementacją wspieranego sprzętowo magazynu kluczy na starszych urządzeniach. Jest bezpiecznym obszarem głównego procesora. Mówiąc dokładniej, TEE uruchamia własny system operacyjny i komunikuje się z głównym systemem operacyjnym tylko poprzez ograniczony interfejs. W przypadku Androida oznacza to, że naruszenie systemu operacyjnego Android (lub jego jądra) nie spowodowałoby naruszenia procesów działających w TEE. Telefony z Androidem już od jakiegoś czasu są wyposażone w TEE i często zdarza się, że czujniki biometryczne telefonu (np. skaner linii papilarnych) są bezpośrednio podłączone do TEE, co oznacza, że ​​dane biometryczne użytkownika końcowego żyją tylko w TEE, nigdy w systemie Android.

StrongBox

W wersji Androida 9 Pie firma Google wprowadziła StrongBox, kolejne podejście do wdrożenia magazynu kluczy wspieranego sprzętowo. Moduł zawiera następujące elementy:

  • Własny procesor.
  • Prawdziwy generator liczb losowych.
  • Dodatkowe mechanizmy zabezpieczające

Oznacza to, że do urządzenia jest dodawany oddzielny układ sprzętowy (mikroprocesor). Wszystkie wrażliwe operacje kryptograficzne odbywają się na tym układzie. Taki bezpieczny element jest odporne na szerszą gamę ataków, zarówno logicznych, jak i fizycznych, takich jak ataki z boku kanału. Na co dzień używasz takiego chipu np.: w kartcie SIM.

StrongBox obsługuje następujący podzbiór algorytmów i rozmiarów kluczy:

  • RSA 2048
  • AES 128 i 256
  • ECDSA P-256
  • HMAC-SHA256 (obsługuje klucze o wielkości od 8 bajtów do 64 bajtów włącznie)
  • Tripple DES 168

Aby upewnić się, że klucz jest generowany i przechowywany za pomocą StrongBox, możesz użyć KeyGenParameterSpec.Builder.setIsStrongBoxBacked(true) podczas generowania klucza co zapewni ochronę klucza za pomocą StrongBox. 

Generowanie klucza asymetrycznego

Na początku musimy pobrać obiekt KeyStore, który odpowiada za przechowywanie kluczy.

 val keyStore : KeyStore = KeyStore.getInstance("AndroidKeyStore")
keyStore.load(null)

Metoda getInstance(„type”) tworzy instancję KeyStore z danym typem, przechodząc przez listę zarejestrowanych dostawców, zaczynając od najbardziej preferowanego. W naszym przypadku używamy „AndroidKeyStore”.

Po uzyskaniu instancji należy wywołać metodę load(loadStoreParameter), która załaduje dane magazynu kluczy na podstawie dostarczonych parametrów. Parametry ochrony można wykorzystać do sprawdzenia integralności danych lub do ochrony poufności danych z magazynu kluczy. W przypadku dostawcy AndroidKeyStore wystarczy podać wartość null jako parametr, a system załaduje dane na podstawie identyfikatora aplikacji.

W systemie Android klucze asymetryczne są tworzone za pomocą KeyPairGenerator. Zanim przejdziemy do generowania kluczy musimy zdecydować jakiego algorytmu będziemy używać. Tutaj przychodzi nam na pomoc dokumentacja Androida.

Aby mieć kompatybilność wsteczną, musimy wybrać algorytm RSA. Jedyny dostępny algorytm, którego możemy użyć na urządzeniach z Androida 18+.

fun createAndroidKeyStoreAsymmetricKey(alias: String): KeyPair {
    val generator = KeyPairGenerator.getInstance(KeyProperties.KEY_ALGORITHM_RSA, "AndroidKeyStore")

    val key = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M)
        createKeyWithKeyGenParameterSpec(generator, alias)
    else
        createKeyWithKeyPairGeneratorSpec(generator, alias)

    generator.initialize(key)
    // Generates Key with given spec and saves it to the KeyStore
    return generator.generateKeyPair()
}

Podobnie jak wyżej, istnieje metoda getInstance(), której należy użyć do utworzenia klucza. Podajemy w niej algorytm „RSA” oraz dostawcę.

Istnieje również inna, uproszczona wersja tej metody getInstance(“algorithm”). Nie używaj jej! Ta metoda szuka algorytmu wśród wszystkich istniejących dostawców. Nazwy algorytmów są wspólne u różnych dostawców, RSA istnieje prawie wszędzie. Problem może pojawić się przy pobieraniu klucza z magazynu kluczy. Dlatego warto wyraźnie zdefiniować dostawcę, którego chcemy używać.

Gdy mamy już instancję, musimy zbudować KeyGenParameterSpec, aby przejść do metody inicjowania KeyGenerator.

Gdy posiadamy już specyfikację kluczy, używamy metody generateKeyPair(), aby utworzyć parę kluczy prywatny – publiczny. W przypadku dostawcy AndroidKeyStore klucze zostaną automatycznie zapisane w magazynie.

Tworzenie specyfikacji klucza

Czym jest KeyGenParameterSpec? To właściwości klucza. Na przykład, chcemy, aby klucz wygasł po pewnym czasie.

info

Uwaga! KeyGenParameterSpec służy do inicjalizacji kluczy asymetrycznych i symetrycznych. W Androidzie API <23 musimy skorzystać z KeyPairGeneratorSpec.

Aby zbudować specyfikacje kluczy skorzystamy z dwóch metod w zależności od posiadanej wersji Androida.

@RequiresApi(Build.VERSION_CODES.M)
private fun createKeyWithKeyGenParameterSpec(generator: KeyPairGenerator, alias: String): KeyGenParameterSpec {
    return KeyGenParameterSpec.Builder(alias, KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT)
        .setBlockModes(KeyProperties.BLOCK_MODE_ECB)
        .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_RSA_PKCS1)
        .build()
}
private fun createKeyWithKeyPairGeneratorSpec(generator: KeyPairGenerator, alias: String): KeyPairGeneratorSpec {
    val startDate = Calendar.getInstance()
    val endDate = Calendar.getInstance()
    endDate.add(Calendar.YEAR, 10)

    return KeyPairGeneratorSpec.Builder(context)
        .setAlias(alias)
        .setSerialNumber(BigInteger.ONE)
        .setSubject(X500Principal("CN=${alias} CA Certificate"))
        .setStartDate(startDate.time)
        .setEndDate(endDate.time)
        .build()
}

Pierwszą rzeczą jest przekazanie aliasu, którego chcemy użyć. Pamiętaj, że może to być cokolwiek. Jeśli próbujesz zapisać klucz w magazynie kluczy z już istniejącym aliasem, zostanie on zastąpiony nowym kluczem.

KeyGenParameterSpec wymaga określenia celów użycia klucza. Na przykład klucz utworzony za pomocą KeyProperties.PURPOSE_ENCRYPT nie może być używany do odszyfrowywania.

Ponadto musisz określić tryb blokowania i dopełnienie szyfrowania, których chcesz używać z tym kluczem.

Klucze asymetryczne muszą być podpisane certyfikatem. Certyfikat wymaga czasu trwania ważności. Te parametry możemy ustawić za pomocą setStartDatei() setEndDate(). W naszym przypadku generujemy fałszywy certyfikat jak pokazano wyżej w metodzie createKeyWithKeyPairGeneratorSpec().

W przypadku Androida z API > 23 nie ma już potrzeby ręcznego definiowania certyfikatu. KeyGenParameterSpec zrobi to automatycznie. Nadal możesz dostosować wartości domyślne za pomocą:

.setCertificateNotBefore() // Domyślnie data to 1 stycznia 1970 r.
.setCertificateNotAfter() // Domyślnie data to 1 stycznia 2048 r.
.setCertificateSerialNumber() // Domyślnie numerem seryjnym jest 1.
.setCertificateSubject() // Domyślnie tematem jest CN = fałszywy.

Szyfrowanie i deszyfrowanie danych za pomocą kluczy asymetrycznych

W systemie Android szyfrowanie i deszyfrowanie odbywa się za pomocą klasy Cipher. W pierwszej kolejności musimy utworzyć obiekt. W metodzie getInstance() podajemy typ transformacji szyfrowania.

info

Transformacja reprezentuje algorytm, który będzie używany do szyfrowania lub deszyfrowania, w formacie: „Algorytm / Tryb / Wypełnienie”.

val cipher = Cipher.getInstance("RSA/ECB/PKCS1Padding")
cipher.init(MODE, key)

W powyższym przykładzie utworzono obiekt Cipher przy użyciu algorytmu szyfrowania o nazwie RSA.

Niektóre algorytmy szyfrowania mogą działać w różnych trybach. Tryb szyfrowania określa szczegóły dotyczące sposobu szyfrowania danych przez algorytm. Tryby są oddzielne od samych algorytmów szyfrowania, tak więc są to raczej „dodatki” do algorytmów. W powyższym przykładzie został wybrany tryp EBC – elektroniczny dziennik kodów, który został omówiony wcześniej.

Oprócz tego szyfrowanie bloków również wymaga „schematu wypełniania”, jest on dołączany na końcu ciągu nazwy transformacji. My skorzystaliśmy z PKCS1Padding.

Przed użyciem instancji Cipher należy ją zainicjować. Inicjujesz, wywołując jej metodę init(). Pierwszy parametr odpowiada za tryb działania tj. czy chcemy szyfrować dane, czy deszyfrować. W drugim parametrze podajemy klucz. Tutaj w zależności od wykonywanej operacji podajemy klucz publiczny (do szyfrowania danych) lub klucz prywatny (do odszyfrowania danych).

Po inicjalizacji użyj doFinal(data), aby przetworzyć zaszyfrowane lub odszyfrowane dane za pomocą Cipher.

val TRANSFORMATION_ASYMMETRIC = "RSA/ECB/PKCS1Padding"

val cipher = Cipher.getInstance(TRANSFORMATION_ASYMMETRIC)
cipher.init(Cipher.ENCRYPT_MODE, key)

val bytes = cipher.doFinal(data.toByteArray())
val encodedData = Base64.encodeToString(bytes, Base64.DEFAULT)
val TRANSFORMATION_ASYMMETRIC = "RSA/ECB/PKCS1Padding"

val cipher = Cipher.getInstance(TRANSFORMATION_ASYMMETRIC)
cipher.init(Cipher.DECRYPT_MODE, key)

val encryptedData = Base64.decode(data, Base64.DEFAULT)
val decodedData = cipher.doFinal(encryptedData)

Dane zaszyfrowane są w formacie tablicy bajtów, dlatego konwertujemy jest na String za pomocą Base64. W takiej postaci możemy dane zapisać w pamięci urządzenia. Przy deszyfrowaniu ten ciąg znaków dekodujemy i deszyfrujemy.

thumb_up

Inicjowanie instancji Cipher jest kosztowną operacją. Dlatego dobrym pomysłem jest ponowne użycie jej. Po wywołaniu metody doFinal(), obiekt Cipher jest przywracany do stanu, który miał tuż po inicjalizacji.

Generowanie kluczy symetrycznych

Algorytm RSA nie był zaprojektowany do pracy z dużą ilością danych. Możesz przetwarzać wiadomości tylko o ograniczonej długości, która zależy od wielkości klucza. Im większy klucz, tym większa wiadomość może zostać zaszyfrowana. Należy pamiętać, że użycie dużych rozmiarów kluczy wydłuży czas szyfrowania i może wpłynąć na wydajność aplikacji. Unikaj szyfrowania dużych danych w głównym wątku aplikacji. Podczas generowania klucza możesz określić rozmiar klucza za pomocą metody setKeySize().

Wartość domyślna zależy od dostawcy i wersji systemu operacyjnego. Obsługiwane rozmiary kluczy RSA to: 512, 768, 1024, 2048, 3072, 4096. Sprawdź więcej informacji na temat obsługiwanych algorytmów i rozmiarów kluczy w dokumentacji.

Najdłuższa wiadomość, jaką możemy uzyskać za pomocą klucza RSA, może zawierać maksymalnie 468 bajtów (przy użyciu klucza 4096-bitowego). Jeśli posiadamy tekst w standardzie UTF-8 to długość tekstu do zaszyfrowania wynosi 468 znaków.

Aby szyfrować duże ilości danych w Androidzie musimy skorzystać z szyfrowania symetrycznego.

Jak już wspomniono klucze symetryczne zostały wprowadzone w Androidzie 23+. Dlatego we wcześniejszych wersjach Androida musisz rozważyć dwie opcje:

  1. Podzielić tekst na części i odpowiednio zaszyfrować / odszyfrować każdą część osobno.
  2. Utworzyć klucz symetryczny z jednym z domyślnych dostawców Javy. Zaszyfrować / odszyfrować wiadomość za jego pomocą. Następnie zaszyfrować ten klucz za pomocą klucza publicznego (patrz poprzednia sekcja) i zapisać go gdzieś na dysku. Podczas odszyfrowywania danych, odszyfruj klucz symetryczny za pomocą klucza prywatnego i użyj go do odszyfrowania wiadomości.

Opcja pierwsza wydaje się dość ciekawa, ale ma pewne wady. Musisz dużo uwagi poświęcić na dzielnie i obliczanie odpowiedniej długości wiadomości. Dodatkowo marnujemy dużo zasobów urządzenia. Datego ta metoda nie jest efektywana. Pozostaje nam druga opcja. Wydaje się dość skomplikowana, ale tak naprawdę jest inaczej.

Domyślnym dostawcą Java w Androidzie jest Bouncy Castle (BC).

info

Uwaga! W Androidzie P usunięto dostawcę BC. Na wcześniejszych wersjach Androida będzie działać poprawnie i zapisze ostrzeżenie w dzienniku aplikacji. W przypadku aplikacji kierowanych na system Android API 28+ wystąpi błąd typu NoSuchAlgorithmException.

 private fun createDefaultSymmetricKey(alias: String) {
        val symmetricKey =  KeyGenerator .getInstance ( "AES" "BC" ).generateKey()
        val asymmetricKey = createAndroidKeyStoreAsymmetricKey(alias)
        val encryptedSymmetricKey = wrapKey(symmetricKey, asymmetricKey.public)
        storage.saveEncryptionKey(alias, encryptedSymmetricKey)
    }

Aby chronić nasz klucz AES i bezpiecznie zapisać go na w pamięci masowej urządzenia, użyjemy klucza RSA, który jest przechowywany w AndroidKeyStore, do jego szyfrowania i deszyfrowania. Ten proces jest również znany jako zawijanie kluczy.

W tym celu skorzystamy z klasy Cipher, która ma tryb działania WRAP_MODE / UNWRAP_MODE oraz metody wrap() / unwrap().

fun wrapKey(keyToBeWrapped: Key, keyToWrapWith: Key?): String {
    val cipher: Cipher = Cipher.getInstance(TRANSFORMATION_ASYMMETRIC)
    cipher.init(Cipher.WRAP_MODE, keyToWrapWith)
    val decodedData = cipher.wrap(keyToBeWrapped)
    return Base64.encodeToString(decodedData, Base64.DEFAULT)
}

fun unWrapKey(wrappedKeyData: String?, keyToUnWrapWith: Key?): Key {
    val cipher: Cipher = Cipher.getInstance(TRANSFORMATION_ASYMMETRIC)
    cipher.init(Cipher.UNWRAP_MODE, keyToUnWrapWith)
    val encryptedKeyData = Base64.decode(wrappedKeyData, Base64.DEFAULT)
    return cipher.unwrap(encryptedKeyData, "AES", Cipher.SECRET_KEY)
}

I oto w ten sposób możemy tworzyć klucze symetryczne w Androidzie 18+. W wersji Marshmallow i wyżej klucze symetryczny generujemy w następujący sposób:

@RequiresApi(Build.VERSION_CODES.M)
private fun createAndroidKeyStoreSymmetricKey(alias: String) {
    val keyGenerator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, "AndroidKeyStore")
    val builder = KeyGenParameterSpec.Builder(alias, KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT)
        .setBlockModes(KeyProperties.BLOCK_MODE_GCM )
        .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE )
    keyGenerator.init(builder.build())
    keyGenerator.generateKey()
}

Szyfrowanie i deszyfrowanie danych za pomocą kluczy symetrycznych

Część szyfrowania i deszyfrowania jest dość łatwa. Praktycznie niczym się nie różni od szyfrowania, deszyfrowania danych za pomocą kluczy asymetrycznych oprócz jednego elementu.

Musimy pobrać odniesienie do wektora inicjalizacji (IV), który będzie potrzebny do odszyfrowania. Zazwyczaj wymagana jest losowość lub pseudolosowa. Jest to wymagane w trybach algorytmu blokowego u większości dostawców, w tym zarówno dostawcy AndroidKeyStore, jak i BC. Jeśli IV nie został określony podczas deszyfrowania, Cipher będzie wyrzucał wyjątek IllegalArgumentException lub InvalidKeyExceptionzostanie w zależności od systemu.

Najłatwiejszym sposobem wdrożenia obsługi wektora inicjowania jest użycie danych z tablicy bajtów, które są generowane przez Cipher podczas szyfrowania. Można go uzyskać przez metodę getIV(). Następnie podczas deszyfrowania zainicjuj Cipher z wygenerowanej wartością IV. Spójrz na poniższy kod:

private fun encrypt(data: String, key: Key): String {
    val cipher: Cipher = Cipher.getInstance(TRANSFORMATION_GCM)
    cipher.init(Cipher.ENCRYPT_MODE, key)

    val iv = cipher.iv
    val ivString = Base64.encodeToString(iv, Base64.DEFAULT)
    var result = ivString + IV_SEPARATOR

    val bytes = cipher.doFinal(data.toByteArray())
    result += Base64.encodeToString(bytes, Base64.DEFAULT)

    return result
}
private fun decrypt(data: String, key: Key): String {
    var encodedString: String
    val cipher: Cipher = Cipher.getInstance(TRANSFORMATION_GCM)

    val split = data.split(IV_SEPARATOR.toRegex())
    if (split.size != 2)
        throw IllegalArgumentException("Passed data is incorrect. There was no IV specified with it.")

    val ivString = split[0]
    encodedString = split[1]
    val ivSpec = GCMParameterSpec(128, Base64.decode(ivString, Base64.DEFAULT) )

    cipher.init(Cipher.DECRYPT_MODE, key, ivSpec)

    val encryptedData = Base64.decode(encodedString, Base64.DEFAULT)
    val decodedData = cipher.doFinal(encryptedData)
    return String(decodedData)
}

Możesz zauważyć, że w tym przykładzie został użyty algorytm GCM.

AES-GCM jest bezpieczniejszym szyfrem niż AES-CBC oraz wydajnieszy. Tryb Galois / Counter działa zupełnie inaczej. Działanie licznika ma na celu przekształcenie szyfrów blokowych w szyfry strumieniowe, w których każdy blok jest szyfrowany pseudolosową wartością z „strumienia klucza”. Ta koncepcja osiąga to poprzez użycie kolejnych wartości inkrementującego licznika, tak że każdy blok jest szyfrowany unikalną wartością, która prawdopodobnie nie powtórzy się.

Możliwe jest sytuacja gdy getIV() zwraca „pustą” tablicę lub null. Aby uniknąć takiej niespodzianki, zadeklaruj wektor Inicjalizacji, używając klasy SecureRandom. Klasa SecureRandom służy do generowania pseudolosowej liczby za pomocą algorytmu PRNG.

val iv = ByteArray(12)
SecureRandom().nextBytes(iv)

val paramSpec = GCMParameterSpec(128, iv)

cipher.init(Cipher.ENCRYPT_MODE, keySpec, paramSpec)
cipher.init(Cipher.DECRYPT_MODE, keySpec, paramSpec)

W przypadku GCM NIST zaleca 12 bajtową (a nie 16) losową tablicę bajtów, ponieważ jest ona szybsza i bezpieczniejsza. Pamiętaj, aby zawsze używać silnego generatora liczb pseudolosowych (PRNG), takiego jak SecureRandom.

info

Wygenerowana wartość musi zostać przekazana do metody init() zarówno dla trybów szyfrowania, jak i deszyfrowania.

Jeśli szyfrujesz duże porcje danych, zajrzyj do CipherInputStream, aby całość nie musiała być ładowana na stos.

Zarządzanie kluczami

KeyStore zapewnia metody, które pomagają nam zarządzać zapisanymi kluczami:.

val privateKey = keyStore.getKey(alias, null) as PrivateKey?
val publicKey = keyStore.getCertificate(alias)?.publicKey
val pair = KeyPair(publicKey, privateKey)
keystore.deleteEntry(alias)

Metoda getKey(„alias”, „passwrod”) – zwraca klucz z podanym aliasem lub null, jeśli dany alias nie istnieje. W AndroidKeyStore nie jest wymagane hasło.

Metoda getCertificate(„alias”) – zwraca certyfikat lub null, jeśli podany alias nie istnieje lub nie zawiera certyfikatu.

Metoda deleteEntry(„alias”) – usuwa klucz z podanym aliasem. Jeśli wpisu nie można usunąć zostanie zwrócony bład KeyStoreException.

Metoda aliases() pobiera wszystkie aliasy z magazynu kluczy.

Weryfikacja danych

Po otrzymaniu zaszyfrowanych danych z samych danych nie widać, czy zostały one zmodyfikowane podczas transportu. Zobaczmy w jaki sposób możemy te informacje zweryfikować.

Skróty wiadomości

MessageDigest jest najprostszym, zarówno pod względem koncepcji, jak i użycia spośród dostępnych mechanizmów weryfikacji danych.

Aby utworzyć instancję MessageDigest, wywołujesz znaną Ci metodę getInstance(). Parametr przekazany do tej funkcji to nazwa konkretnego algorytmu, którego należy użyć do obliczenia skrótu. Algorytmy te są opisane w dokumentacji MessageDigest. Android zapewnia następujące algorytmy:

 val bytes = cipher.doFinal(data.toByteArray())
val messageDigest = MessageDigest.getInstance ("SHA-256")
val digest = messageDigest.digest(bytes)

Message Authentication Code (MAC)

Kod uwierzytelnienia wiadomości jest to jednokierunkowa funkcja wykorzystująca klucz tajny w celu utworzenia skrótu wiadomości. Kody uwierzytelnienia wiadomości wykorzystywane są do uwierzytelnienia danych oraz zapewnienia ich integralności. Od klasycznych funkcji jednokierunkowych odróżnia je to, że poprawność wiadomości mogą sprawdzić tylko osoby dysponujące kluczem tajnym.

Standardowy kod MAC zapewnia ochronę integralności, ale może podlegać sfałszowaniu, jeśli nie jest zabezpieczony dodatkowym mechanizmem chroniącym jego autentyczność (np. podpisem cyfrowym). Dlatego stworzono HMAC, w którym podczas każdej operacji dodawany jest tajny klucz:

 // create a MAC and initialize with the key
val mac: Mac = Mac.getInstance(key.algorithm)
mac.init(key)
// create a digest from the byte array
val digest = mac.doFinal(data)
info

Jeżeli klucz zostanie wygenerowany z algorytmem AES i zastosujesz powyższy kod, dostaniesz błąd typu NoSuchAlgorithmException. W tym przypadku musisz zmienić algorytm generowania klucza, na przykład na „HmacSHA256”.

KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_HMAC_SHA256, „BC”) // API <18 KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_HMAC_SHA256, "AndroidKeyStore") // API 23+

Jeśli masz wiele bloków danych do obliczenia np. czytasz plik blok po bloku, musisz wywołać metodę update() z każdym blokiem i na samym końcu wywołać doFinal(). Oto przykład:

val data = "..." 
val data2 = "..." 

mac.update (data)
mac.update (data2)

val digest = mac.doFinal()

Podpis cyfrowy

Klasa Signature może utworzyć podpis cyfrowy dla danych binarnych. Podpis cyfrowy to skrót wiadomości szyfrowany kluczem prywatnym (szyfrowanie asymetryczne). Każdy, kto posiada klucz publiczny, może zweryfikować podpis cyfrowy.

Aby móc korzystać z podpisu cyfrowego, musisz utworzyć instancję klasy Signature.

// generate a key from the generator
val signature = Signature.getInstance ("SHA256WithDSA")
val secureRandom = SecureRandom()

signature.initSign(key.private, secureRandom)

Łańcuch przekazany jako parametr do metody getInstance() to nazwa używanego algorytmu podpisu cyfrowego. Nastepnie inicjiujemy kluczem prywatnym oraz instancją SecureRandom.

Po zainicjowaniu można jej użyć do tworzenia podpisów cyfrowych. Tworzysz podpis cyfrowy, wywołując metodę update() jeden lub więcej razy, kończąc wywołaniem na sign(). Oto przykład tworzenia podpisu cyfrowego dla bloku danych binarnych:

signature.update(data)
// getting the signature byte
val signatureBytes: ByteArray = signature.sign()

Jeśli chcesz zweryfikować podpis cyfrowy utworzony przez kogoś innego, musisz zainicjować obiekt Signature w tryb weryfikacji (zamiast trybu podpisu). Oto jak to wygląda:

 val signatureVerify = Signature.getInstance("SHA256WithDSA")
signatureVerify.initVerify(key.public)
signature.update(data)
val verify : Boolean = signatureVerify.verify(data)

Zwróć uwagę, w jaki sposób Signature jest teraz inicjowana. Przekazujemy klucz publiczny jako parametr. Po zainicjowaniu w trybie weryfikacji możesz użyć obiektu do weryfikacji podpisu cyfrowego.

Szyfrowanie plików

Jeżeli zastanawiałeś się, w jaki sposób szyfrować pliki w Androidzie, to już spieszę z pomocą. Otóż cała procedura nie różni się znacząco od tego co zostało przedstawione w poprzednich częściach.

Java pozwala nam wykorzystać wygodną klasę CipherOutputStream do zapisywania zaszyfrowanej treści w innym obiekcie typu OutputStream.

val cipher = Cipher.getInstance(transformation)
cipher.init(Cipher.ENCRYPT_MODE, secretKey)
val iv: ByteArray = cipher.getIV()

val file = File(fileName)
val fs = FileOutputStream(file)

val out = CipherOutputStream(fs, cipher)
out.write(iv)
out.write(text.toByteArray())
out.flush()
out.close()

Pamiętaj, że zapisujemy IV ( wektor inicjalizacji ) na początku pliku. W tym przykładzie IV jest generowany automatycznie podczas inicjalizacji Cipher. Korzystanie z IV jest obowiązkowe w przypadku korzystania z trybu CBC, aby losowo zaszyfrować dane wyjściowe.

Jeżeli chodzi o deszyfrowanie kod wygląda nastepująco:

var content: String = ""
FileInputStream(fileName).use { fileIn ->
    // get IV
    val fileIv = ByteArray(16)
    fileIn.read(fileIv)
    cipher.init(Cipher.DECRYPT_MODE, secretKey, IvParameterSpec(fileIv))
    // get content
    CipherInputStream(fileIn, cipher).use { cipherIn ->
        InputStreamReader(cipherIn).use { inputReader ->
            BufferedReader(inputReader).use { reader ->
                val sb = StringBuilder()
                var line: String?
                while (reader.readLine().also { line = it } != null) {
                    sb.append(line)
                }
                content = sb.toString()
            }
        }
    }
}

W celu odszyfrowania musimy również najpierw pobrać IV. Następnie inicjujemy Cipher i odszyfrowujemy zawartość. Ponownie możemy skorzystać ze specjalnej klasy CipherInputStream, która w przejrzysty sposób zajmuje się rzeczywistym deszyfrowaniem.

Szyfrowanie bazy danych

Baza danych tworzona przy użyciu klasy SQLiteOpenHelper lub biblioteki Room jest wstawiona do domyślnego katalogu danych aplikacji, więc znajdują się pod ochroną. Mimo to istnieje ryzyko wyciągnięcia tego pliku przez zewnętrznego aktora (aplikacje, osobę) i odczytania zawartości. Dlatego nie powinno się przetrzymywać wrażliwych danych w bazie danych. Ewentualnie zaszyfruj tylko tę część informacji, która tego wymaga przed stawieniem do bazy.

Gdy wymagania aplikacji wymagają szyfrowania całej bazy możesz skorzystać z SQLCipher for Android. Projekt również wspiera bibliotekę Room. Sama implementacja jest bardzo prosta, więc nie ma potrzeby jej omawiania dokładniej.

Jetpack Security

Na Google I/O 19 przedstawiono bibliotekę zabezpieczeń, która pozwala nam łatwo szyfrować dane, pliki i preferencje. Zapewnia silne bezpieczeństwo i odpowiednią wydajność. Jest dostępna dla systemu Android w wersji 6.0 lub nowszej. Jeżeli Twoja aplikacja obsługuje niższe wersje Androida, a wymagane jest szyfrowanie danych, będziesz potrzebować niestandardowych opcji, takich jak wyżej zostały przedstawione.

Biblioteka zabezpieczeń, będąca częścią Androida Jetpack, zapewnia wdrożenie najlepszych praktyk bezpieczeństwa związanych z odczytywaniem i zapisywaniem danych, a także tworzenie i weryfikację kluczy. Jetpack Security promuje korzystanie z AndroidKeyStore przy jednoczesnym użyciu bezpiecznych i dobrze znanych prymitywów kryptograficznych. Jetpack Security opiera się na Tink – wieloplatformowym projekcie bezpieczeństwa od firmy Google. 

Komponent oferuje dwa narzędzia:

  • EncryptedSharedPreferences – automatycznie szyfruje klucze i wartości oraz zapisuje je w SharedPreference. 
  • EncryptedFile – umożliwia odczyt / zapis zaszyfrowanych plików poprzez zapewnienie niestandardowych implementacji FileInputStream i FileOutputStream.
info

Uwaga: ta biblioteka jest obecnie dostępna jako biblioteka alfa i dostępne tylko w pakiecie bibliotek AndroidX. Oznacza to, że chociaż funkcjonalność jest stabilna, niektóre elementy interfejsu API mogą zostać zmienione lub usunięte w przyszłych wersjach.

Wszystko, co musisz zrobić, by dodać bibliotekę do swojego projektu, to umieścić odpowiedni wpis w pliku build.gradle:

dependencies {
    implementation "androidx.security:security-crypto:1.0.0-rc02"
}

Sprawdź stronę biblioteki, aby mieć zawsze aktualną wersję.

Zarządzanie kluczami

Biblioteka zabezpieczeń wykorzystuje dwuczęściowy system do zarządzania kluczami:

  • Zestaw kluczy zawierający co najmniej jeden klucz do szyfrowania danych. Sam zestaw kluczy jest przechowywany w obiekcie SharedPreferences.
  • Klucz główny,  który szyfruje wszystkie podklucze używane dla każdej operacji kryptograficznej. Ten klucz jest przechowywany w znanym Ci już AndroidKeyStore.

MasterKeys to klasa pomocnicza, która generuje klucze szyfrujące i przechowuje je w magazynie kluczy Androida.

val keyGenParameterSpec = MasterKeys.AES256_GCM_SPEC
val masterKeyAlias = MasterKeys.getOrCreate(keyGenParameterSpec)

Możliwe jest również zbudowanie własnych KeyGenParameterSpec, wybierając opcje, które będą odpowiednie dla twojego zastosowania. Ważne opcje, z których możesz skorzystać to:

  • userAuthenticationRequired() / userAuthenticationValiditySeconds() – służą do utworzenia klucza czasowego. Klucze czasowe wymagają autoryzacji przy użyciu BiometricPrompt zarówno do szyfrowania, jak i deszyfrowania kluczy symetrycznych.
  • unlockedDeviceRequired() – ustawia flagę, która pomaga uniemożliwić dostęp do klucza, jeśli urządzenie nie zostanie odblokowane. Ta flaga jest dostępna w systemie Android Pie i nowszych.
  • setIsStrongBoxBacked() – aby uruchomić operacje kryptograficzne na oddzielnym układzie. Opcja ta jest dostępna na niektórych urządzeniach z Androidem Pie lub nowszym.
val advancekeyGenParameterSpec = KeyGenParameterSpec.Builder(
    "master_key_alias",
    KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT
).apply {
    setBlockModes(KeyProperties.BLOCK_MODE_GCM)
    setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
    setKeySize(256)
    setUserAuthenticationRequired(true)
    setUserAuthenticationValidityDurationSeconds(30)
    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
        setUnlockedDeviceRequired(true)
    }
}.build()

Gdy wymagane jest uwierzytelnienie użytkownika:

  • Klucz można wygenerować tylko wtedy, gdy skonfigurowano bezpieczny ekran blokady. Ponadto, jeśli klucz wymaga, aby uwierzytelnianie użytkownika odbywało się przy każdym użyciu klucza, należy zarejestrować interfejs biometryczny.
  • Klucz zostanie nieodwracalnie unieważniony, gdy ekran blokady zostanie wyłączony lub gdy zostanie zresetowany (np. przez administratora urządzenia). 

Powyższe wymagania dotyczą tylko operacji na kluczach tajnych i kluczach prywatnych. Operacje na kluczu publicznym nie są ograniczone.

Szyfrowanie SharedPreferences

Jeżeli już korzystałeś z SharedPreferences, nie będziesz miał problemów z szyfrowanieem i deszyfrowaniem. Wystarczy utworzyć lub pobrać klucz główny z magazynu kluczy Androida i użyć go do zainicjowania instancji EncryptedSharedPreferences:

val defaultSharedPreferencesName = context.packageName + "_preferences"
val sharedPreferences = EncryptedSharedPreferences
    .create(
        defaultSharedPreferencesName,
        masterKeyAlias,
        context,
        EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, //for encrypting Keys
        EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM ////for encrypting Values
    )

Zarówno klucze, jak i wartości są szyfrowane. Klucze są szyfrowane za pomocą AES256-SIV-CMAC, wartości AES256-GCM i są powiązane z zaszyfrowanym kluczem. 

Jak zapisać dane i odczytać dane z EncryptedSharedPreferences? Również prosto:

//save data
encryptedSharedPreferences.edit()
    .putString("MY_KEY", "secret_text")
    .apply()

//read data
val myValue = encryptedSharedPreferences.getString("MY_KEY", "")

Jak widać, po utworzeniu EncryptedSharedPreferences możemy go używać tak samo, jak SharedPreferences. I o to chodziło.

Szyfrowanie plików

Jetpack Security zawiera klasę EncryptedFile, która umożliwia odczyt / zapis zaszyfrowanych plików poprzez zapewnienie niestandardowych implementacji FileInputStream FileOutputStream.

Wszystko, co musisz zrobić, to utworzyć plik. Aby zapisać dane do zaszyfrowanego pliku, użyj metody openFileOutput(), a jeśli chcesz odczytać dane z zaszyfrowanego pliku, skorzystaj z openFileInput().

Pamiętaj, że jeśli tworzysz plik, który już ma taką samą nazwę, nadpiszesz go. Pamiętaj, że nazwa pliku nie może zawierać separatorów ścieżki.

val secretFile = File(filesDir, "secret_file.txt")
val encryptedFile = EncryptedFile.Builder(
    secretFile,
    applicationContext,
    masterKeyAlias,
    EncryptedFile.FileEncryptionScheme.AES256_GCM_HKDF_4KB)
    .setKeysetAlias("file_key") // optional
    .setKeysetPrefName("secret_shared_prefs") // optional
    .build()

 // Write data to your encrypted file
encryptedFile.openFileOutput().use { outputStream ->
     outputStream.write("MY SUPER-SECRET INFORMATION".toByteArray())
}

// Read data from your encrypted file
val contents = encryptedFile.openFileInput().bufferedReader().useLines { lines ->
            lines.fold("") { working, line ->
                "$working\n$line"
            }
        }