Android Keystore & Biometria - cz. II [PL]

Android Aug 31, 2023

Android Keystore & Biometria

Bezpieczeństwo danych na skompromitowanym urządzeniu
(część II)

W pierwszym artykule zbadaliśmy stopień trudności uzyskania przez intruza dostępu do kluczy szyfrujących przechowywanych w Android Keystore.
Wnioski nie pozostawiają złudzeń, intruz z wysokimi uprawnieniami może wykorzystać dowolny klucz dowolnej aplikacji, nawet w przypadku rozwiązań wspomaganych sprzętowo (TEE i StrongBox).

W tym artykule, sprawdzimy jak uwierzytelnianie blokadą ekranu lub biometrią, może pomóc w ochronie dostępu do kluczy.
Przyjrzymy się też ciekawemu przypadkowi z drobnym błędem w konfiguracji, który może zniweczyć wysiłek włożony w implementację ochrony z uwierzytelnianiem.

Uwierzytelnianie w Android Keystore

Kryterium uwierzytelniania należy zdefiniować już podczas generowania klucza w Android Keystore.
Aby to zrobić, musimy odwołać się do funkcji setUserAuthenticationRequired(true).
[https://developer.android.com/reference/android/security/keystore/KeyGenParameterSpec.Builder#setUserAuthenticationRequired(boolean)]

Ponadto, mamy do dyspozycji kilka funkcji pozwalających ustawić dodatkowe parametry:

  • setUserAuthenticationParameters(int timeout, int type) - określa czas na jaki klucz zostanie odblokowany oraz wymagany typ uwierzytelniania (blokada ekranu lub biometria)
  • setUserAuthenticationValidWhileOnBody(boolean remainsValid) - określa czy czasowe uwierzytelnianie powinno zostać unieważnione, gdy sensor ciała wykryje rozdzielenie fizycznego kontaktu urządzenia z użytkownikiem
  • setInvalidatedByBiometricEnrollment(boolean invalidateKey) - określa czy klucz powinien zostać unieważniony wraz ze zmianą ustawień biometrii

Po wygenerowaniu klucza, musimy obsłużyć całą procedurę uwierzytelnienia przy pomocy klasy BiometricPrompt.
Przykład poprawnego użycia możemy znaleźć w udostępnionym dla deweloperów tutorialu: https://developer.android.com/training/sign-in/biometric-auth

Mała uwaga: klasa BiometricPrompt posiada dwie oficjalne implementacje, które nieco różnią się między sobą:

1. androidx.biometric.BiometricPrompt
[https://developer.android.com/reference/androidx/biometric/BiometricPrompt]

2. android.hardware.biometrics.BiometricPrompt
[https://developer.android.com/reference/android/hardware/biometrics/BiometricPrompt]

Podczas testów, wykorzystamy implementację z biblioteki androidx.biometric i w tym celu dodajemy odpowiedni wpis w konfiguracji projektu Android Studio.

build.gradle

[...]

dependencies {
   implementation 'androidx.biometric:biometric:1.1.0'
[...]

Bazowa aplikacja

Skorzystamy z bazowej aplikacji z pierwszej części artykułu.
W przykładach dla TEE i StrongBox podążaliśmy za zaleceniami najlepszych praktyk bezpieczeństwa, implementując rozwiązanie z EncryptedFile i MasterKeys.

W bieżących przykładach nie możemy skorzystać z tych rozwiązań, z kilku powodów:

  • klasa EncryptedFile zaimplementowana jest przy użyciu wieloplatformowej biblioteki Google Tink, która nie przewiduje specyficznego dla systemu Android uwierzytelniania
  • nie powinniśmy wymuszać uwierzytelniania na kluczu, który domyślnie wykorzystywany jest przez komponenty nieobsługujące uwierzytelniania
  • klasa MasterKeys nie pozwala na wygenerowanie klucza wymagającego uwierzytelniania przy każdym użyciu

https://androidx.tech/artifacts/security/security-crypto/1.0.0-source/androidx/security/crypto/MasterKeys.java.html

    	if (spec.isUserAuthenticationRequired()
            	&& spec.getUserAuthenticationValidityDurationSeconds() < 1) {
        	throw new IllegalArgumentException(
                	"per-operation authentication is not supported "
                        	+ "(UserAuthenticationValidityDurationSeconds must be >0)");
    	}

W związku z powyższym:

  • wygenerujemy klucz z własnym aliasem
  • napiszemy implementację zbliżoną do EncrptedFile, z tą różnicą, że ze względu na mały rozmiar pliku, zaimplementujemy prosty AEAD zamiast Streaming AEAD

Nowy alias klucza: file_encryption_master_key

Klasa SecureFileBiometricImpl implementuje SecureFile do obsługi pliku zaszyfrowanego algorytmem AES-256-GCM (AEAD), dla którego klucz uwierzytelniany jest poprzez BiometricPrompt. Treść i szczegóły wyświetlanego komunikatu przekazywane są w konstruktorze jako parametr typu BiometricPrompt.PromptInfo.

SecureFileBiometricImpl.java

package pl.isec.baseapp.devicecredential;

import static java.nio.charset.StandardCharsets.UTF_8;
import static java.nio.file.StandardOpenOption.CREATE;
import static java.nio.file.StandardOpenOption.READ;
import static java.nio.file.StandardOpenOption.TRUNCATE_EXISTING;
import static java.nio.file.StandardOpenOption.WRITE;

import android.content.Context;
import androidx.annotation.NonNull;
import androidx.biometric.BiometricPrompt;
import androidx.core.content.ContextCompat;
import androidx.fragment.app.FragmentActivity;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.nio.file.Files;
import java.security.GeneralSecurityException;
import java.security.KeyException;
import java.security.KeyStore;

import javax.crypto.Cipher;
import javax.crypto.SecretKey;
import javax.crypto.spec.GCMParameterSpec;

public final class SecureFileBiometricImpl extends SecureFile {
   private static final String ANDROID_KEYSTORE = "AndroidKeyStore";
   private static final String AUTHENTICATION_FAILED = "Authentication failed";

   private static final String AES_GCM_NOPADDING = "AES/GCM/NoPadding";
   private static final int IV_SIZE_IN_BYTES = 12;
   private static final int TAG_SIZE_IN_BYTES = 16;

   private final BiometricPrompt.PromptInfo mPromptInfo;
   private final FragmentActivity mActivity;

   public SecureFileBiometricImpl(
       @NonNull File file,
       @NonNull Context context,
       @NonNull String keyAlias,
       @NonNull FragmentActivity activity,
       @NonNull BiometricPrompt.PromptInfo promptInfo
   ){
       super(file, context, keyAlias);
       mActivity = activity;
       mPromptInfo = promptInfo;
   }

   @Override
   public void openFileInput(@NonNull OpenFileInputCallback callback){
       try {
           byte[] iv = new byte[IV_SIZE_IN_BYTES];
           InputStream inputStream = Files.newInputStream(mFile.toPath(), READ);
           inputStream.read(iv, 0, IV_SIZE_IN_BYTES);
           inputStream.close();

           Cipher cipher = Cipher.getInstance(AES_GCM_NOPADDING);
           GCMParameterSpec spec = new GCMParameterSpec(8 * TAG_SIZE_IN_BYTES, iv);
           cipher.init(Cipher.DECRYPT_MODE, getSecretKey(), spec);

           newBiometricPrompt(callback).authenticate(
               mPromptInfo,
               new BiometricPrompt.CryptoObject(cipher)
           );
       } catch (Exception e) {
           callback.onError(e.getMessage());
       }
   }

   @Override
   public void openFileOutput(@NonNull OpenFileOutputCallback callback){
       try {
           Cipher cipher = Cipher.getInstance(AES_GCM_NOPADDING);
           cipher.init(Cipher.ENCRYPT_MODE, getSecretKey());

           newBiometricPrompt(callback).authenticate(
               mPromptInfo,
               new BiometricPrompt.CryptoObject(cipher)
           );
       } catch (Exception e) {
           callback.onError(e.getMessage());
       }
   }

   private BiometricPrompt newBiometricPrompt(OpenFileInputCallback callback){
       return new BiometricPrompt(
           mActivity,
           ContextCompat.getMainExecutor(mContext),
           new BiometricPrompt.AuthenticationCallback() {
               @Override
               public void onAuthenticationError(int errorCode, CharSequence errString) {
                   super.onAuthenticationError(errorCode, errString);
                   callback.onError(errString);
               }

               @Override
               public void onAuthenticationSucceeded(BiometricPrompt.AuthenticationResult result) {
                   super.onAuthenticationSucceeded(result);
                   try {
                       byte[] ciphertext = Files.readAllBytes(mFile.toPath());
                       Cipher cipher = result.getCryptoObject().getCipher();
                       cipher.updateAAD(mFile.getName().getBytes(UTF_8));

                       callback.onInputStreamReady(
                           new ByteArrayInputStream(
                               cipher.doFinal(
                                   ciphertext,
                                   IV_SIZE_IN_BYTES,
                                   ciphertext.length - IV_SIZE_IN_BYTES
                               )
                           )
                       );
                   } catch (Exception e){
                       callback.onError(e.getMessage());
                   }
               }

               @Override
               public void onAuthenticationFailed() {
                   super.onAuthenticationFailed();
                   callback.onError(AUTHENTICATION_FAILED);
               }
           });
   }

   private BiometricPrompt newBiometricPrompt(OpenFileOutputCallback callback){
       return new BiometricPrompt(
               mActivity,
               ContextCompat.getMainExecutor(mContext),
               new BiometricPrompt.AuthenticationCallback() {
                   @Override
                   public void onAuthenticationError(int errorCode, CharSequence errString) {
                       super.onAuthenticationError(errorCode, errString);
                       callback.onError(errString);
                   }

                   @Override
                   public void onAuthenticationSucceeded(BiometricPrompt.AuthenticationResult result) {
                       super.onAuthenticationSucceeded(result);
                       try {
                           ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
                           callback.onOutputStreamReady(outputStream);
                           byte[] plaintext = outputStream.toByteArray();

                           Cipher cipher = result.getCryptoObject().getCipher();
                           byte[] ciphertext = new byte[
                               IV_SIZE_IN_BYTES + cipher.getOutputSize(plaintext.length)
                           ];

                           System.arraycopy(cipher.getIV(), 0, ciphertext, 0, IV_SIZE_IN_BYTES);
                           cipher.updateAAD(mFile.getName().getBytes(UTF_8));
                           cipher.doFinal(
                               plaintext,0, plaintext.length,
                               ciphertext, IV_SIZE_IN_BYTES
                           );

                           Files.write(mFile.toPath(), ciphertext, CREATE, WRITE, TRUNCATE_EXISTING);
                       } catch (Exception e){
                           callback.onError(e.getMessage());
                       }
                   }

                   @Override
                   public void onAuthenticationFailed() {
                       super.onAuthenticationFailed();
                       callback.onError(AUTHENTICATION_FAILED);
                   }
               });
   }

   private SecretKey getSecretKey() throws IOException, GeneralSecurityException {
       KeyStore ks = KeyStore.getInstance(ANDROID_KEYSTORE);
       ks.load(null);

       if (!ks.containsAlias(mKeyAlias)) {
           throw new KeyException("Key alias not found: "+ mKeyAlias);
       }
       return (SecretKey) ks.getKey(mKeyAlias, null);
   }
}

Aplikacja intruza

W pierwszej części artykułu testowaliśmy rozwiązania z poziomu graficznej oraz konsolowej aplikacji intruza.
Wykazaliśmy, że w przypadku graficznej wersji, wystarczy zwykła aplikacja uruchomiona z odpowiednimi uprawnieniami. Jednakże, metoda ta wymaga ponownego uruchomienia środowiska ART, co nie ujdzie uwadze użytkownika.
W tej części artykułu, ograniczymy się do dyskretnej metody z implementacją konsolowej aplikacji intruza, pozbawionej możliwości interakcji z użytkownikiem.

W stosunku do poprzedniej aplikacji, dostosujemy jedynie alias klucza oraz sposób odczytu pliku, aby był zgodny z implementacją SecureFileBiometricImpl.

  • Android Studio -> New Project -> No Activity
  • Language: Java
  • Minimum SDK: API 31: Android 12.0 (S)

Nazwa pakietu: pl.isec.robber.console.securefilebiometricimpl

Run.java

package pl.isec.robber.console.securefilebiometricimpl;

import static java.nio.charset.StandardCharsets.UTF_8;
import static java.nio.file.StandardOpenOption.READ;

import android.annotation.SuppressLint;
import android.app.Application;
import android.app.Instrumentation;
import android.content.Context;
import android.content.pm.ApplicationInfo;
import android.os.Looper;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.FileInputStream;
import java.io.InputStream;
import java.lang.reflect.Constructor;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.nio.file.Files;
import java.security.KeyException;
import java.security.KeyStore;

import javax.crypto.Cipher;
import javax.crypto.SecretKey;
import javax.crypto.spec.GCMParameterSpec;

public class Run {
   private static final String MASTER_KEY_ALIAS = "file_encryption_master_key";
   private static final String ANDROID_KEYSTORE = "AndroidKeyStore";
   private static final String AES_GCM_NOPADDING = "AES/GCM/NoPadding";
   private static final int IV_SIZE_IN_BYTES = 12;
   private static final int TAG_SIZE_IN_BYTES = 16;

   public static void main(String args[]){
       if(args.length != 2){
           System.err.println("Usage:\n\tpl.isec.robber.console.securefileimpl.Run <packageName> <encryptedFileName>");
           System.exit(1);
       }
       String packageName = args[0];
       String fileName = args[1];

       try {
           /** Initialize Android Keystore and application context **/
           initAndroidKeystore();
           Context context = initAppContext(packageName);

           /** Load SecretKey **/
           KeyStore ks = KeyStore.getInstance(ANDROID_KEYSTORE);
           ks.load(null);

           if (!ks.containsAlias(MASTER_KEY_ALIAS)) {
               throw new KeyException("Key alias not found: "+ MASTER_KEY_ALIAS);
           }
           SecretKey secretKey = (SecretKey) ks.getKey(MASTER_KEY_ALIAS, null);

           /** Read encrypted file **/
           File encryptedFile = new File(context.getFilesDir(), fileName);
           InputStream inputStream = Files.newInputStream(encryptedFile.toPath(), READ);

           byte[] iv = new byte[IV_SIZE_IN_BYTES];
           int ciphertextSize = inputStream.available() - IV_SIZE_IN_BYTES;
           byte[] ciphertext = new byte[ciphertextSize];

           inputStream.read(iv, 0, IV_SIZE_IN_BYTES);
           inputStream.read(ciphertext, 0, ciphertextSize);
           inputStream.close();

           /** Decrypt data **/
           Cipher cipher = Cipher.getInstance(AES_GCM_NOPADDING);
           GCMParameterSpec spec = new GCMParameterSpec(8 * TAG_SIZE_IN_BYTES, iv);
           cipher.init(Cipher.DECRYPT_MODE, secretKey, spec);
           cipher.updateAAD(encryptedFile.getName().getBytes(UTF_8));
           byte[] plaintext = cipher.doFinal(ciphertext);

           /** Print decrypted message **/
           System.out.println(
               new String(plaintext, UTF_8)
           );
       } catch(Exception e){
           e.printStackTrace();
       }
   }

   @SuppressLint({"BlockedPrivateApi"})
   private static void initAndroidKeystore() throws ClassNotFoundException, NoSuchMethodException, InvocationTargetException, IllegalAccessException {
       /** static AndroidKeyStoreProvider.install() **/
       Class cAndroidKeyStoreProvider = Class.forName("android.security.keystore2.AndroidKeyStoreProvider");
       Method install = cAndroidKeyStoreProvider.getDeclaredMethod("install");
       install.invoke(cAndroidKeyStoreProvider);
   }

   private static Context initAppContext(String packageName) throws ClassNotFoundException, NoSuchMethodException, InvocationTargetException, IllegalAccessException, InstantiationException {
       /** Required for ActivityThread **/
       Looper.prepareMainLooper();

       /** static ActivityThread.systemMain() **/
       Class cActivityThread = Class.forName("android.app.ActivityThread");
       Method systemMain = cActivityThread.getDeclaredMethod("systemMain");
       Object activityThread = systemMain.invoke(cActivityThread);

       /** virtual activityThread.getPackageInfo(...) **/
       Class cCompatibilityInfo = Class.forName("android.content.res.CompatibilityInfo");
       Constructor compatibilityInfoConstructor = cCompatibilityInfo.getConstructor(ApplicationInfo.class, int.class, int.class, boolean.class);
       Object compatibilityInfo = compatibilityInfoConstructor.newInstance(new ApplicationInfo(), 0x02, 200, true);

       Method getPackageInfo = cActivityThread.getDeclaredMethod("getPackageInfo", String.class, cCompatibilityInfo, int.class);
       Object loadedApk = getPackageInfo.invoke(activityThread, packageName, compatibilityInfo, 0);

       /** virtual loadedApk.makeApplication(...) **/
       Class cLoadedApk = Class.forName("android.app.LoadedApk");
       Method makeApplication = cLoadedApk.getDeclaredMethod("makeApplication", boolean.class, Instrumentation.class);
       Application application = (Application) makeApplication.invoke(loadedApk,true, null);

       return application.getApplicationContext();
   }
}

Uwierzytelnianie blokadą ekranu

Aby wymusić i obsłużyć uwierzytelnianie klucza blokadą ekranu, ustawiamy:

Bazowa aplikacja

Implementując funkcję initialize(), bazujemy na kodzie z testów dla StrongBox, zmieniamy alias, wymuszamy uwierzytelnianie oraz przekazujemy odpowiednio zbudowany PromptInfo.

Nazwa pakietu: pl.isec.baseapp.devicecredential

MainActivity.java

private void initialize() throws GeneralSecurityException, IOException {
   /** Get or create key **/
   KeyGenParameterSpec masterKeySpec = createAES256GCMKeyGenParameterSpec();
   String masterKeyAlias = masterKeySpec.getKeystoreAlias();

   KeyStore ks = KeyStore.getInstance(ANDROID_KEYSTORE);
   ks.load(null);

   if (!ks.containsAlias(masterKeyAlias)) {
       KeyGenerator keyGenerator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, ANDROID_KEYSTORE);
       keyGenerator.init(masterKeySpec);
       keyGenerator.generateKey();
   }

   /** Get security level **/
   SecretKey secretKey = (SecretKey) ks.getKey(masterKeyAlias, null);
   SecretKeyFactory factory = SecretKeyFactory.getInstance(secretKey.getAlgorithm(), ANDROID_KEYSTORE);
   KeyInfo keyInfo = (KeyInfo) factory.getKeySpec(secretKey, KeyInfo.class);

   switch(keyInfo.getSecurityLevel()){
       case KeyProperties.SECURITY_LEVEL_SOFTWARE:
           mSecurityLevel = "Software"; break;
       case KeyProperties.SECURITY_LEVEL_TRUSTED_ENVIRONMENT:
           mSecurityLevel = "TEE"; break;
       case KeyProperties.SECURITY_LEVEL_STRONGBOX:
           mSecurityLevel = "StrongBox"; break;
       default:
           mSecurityLevel = "Unknown";
   }

   /** Build PromptInfo for SecureFile **/
   BiometricPrompt.PromptInfo promptInfo = new BiometricPrompt.PromptInfo.Builder()
           .setTitle("Unlock encryption key")
           .setSubtitle("Log in using your device credential")
           .setAllowedAuthenticators(BiometricManager.Authenticators.DEVICE_CREDENTIAL)
           .build();

   /** Create instance of the SecureFile **/
   mSecureFile = new SecureFileBiometricImpl(
           new File(getFilesDir(), FILENAME),
           this,
           masterKeyAlias,
           MainActivity.this,
           promptInfo
   );
}

private static KeyGenParameterSpec createAES256GCMKeyGenParameterSpec(){
   KeyGenParameterSpec.Builder builder = new KeyGenParameterSpec.Builder(
       "file_encryption_master_key",
       KeyProperties.PURPOSE_ENCRYPT | KeyProperties.PURPOSE_DECRYPT)
       .setBlockModes(KeyProperties.BLOCK_MODE_GCM)
       .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
       .setKeySize(256)
       .setIsStrongBoxBacked(true)
       .setUserAuthenticationRequired(true)
       .setUserAuthenticationParameters(0, KeyProperties.AUTH_DEVICE_CREDENTIAL)
       .setInvalidatedByBiometricEnrollment(true);

   return builder.build();
}

Instalujemy aplikację:

$ adb install ~/AndroidStudioProjects/KsDeviceCredential/app/release/app-release.apk

Uruchamiamy:

I otrzymujemy komunikat, że coś poszło nie tak…

W logach urządzenia (komenda adb logcat) znajdujemy jednoznaczną informację o źródle problemu.

System.err: java.security.InvalidAlgorithmParameterException: java.lang.IllegalStateException: Secure lock screen must be enabled to create keys requiring user authentication
System.err:	at android.security.keystore2.AndroidKeyStoreKeyGeneratorSpi.engineInit(AndroidKeyStoreKeyGeneratorSpi.java:262)
System.err:	at android.security.keystore2.AndroidKeyStoreKeyGeneratorSpi$AES.engineInit(AndroidKeyStoreKeyGeneratorSpi.java:63)
System.err:	at javax.crypto.KeyGenerator.init(KeyGenerator.java:519)
System.err:	at javax.crypto.KeyGenerator.init(KeyGenerator.java:502)
System.err:	at pl.isec.baseapp.devicecredential.MainActivity.initialize(MainActivity.java:126)
System.err:	at pl.isec.baseapp.devicecredential.MainActivity.onCreate(MainActivity.java:50)
System.err:	at android.app.Activity.performCreate(Activity.java:8290)
System.err:	at android.app.Activity.performCreate(Activity.java:8269)
System.err:	at android.app.Instrumentation.callActivityOnCreate(Instrumentation.java:1384)
System.err:	at android.app.ActivityThread.performLaunchActivity(ActivityThread.java:3657)
System.err:	at android.app.ActivityThread.handleLaunchActivity(ActivityThread.java:3813)
System.err:	at android.app.servertransaction.LaunchActivityItem.execute(LaunchActivityItem.java:101)
System.err:	at android.app.servertransaction.TransactionExecutor.executeCallbacks(TransactionExecutor.java:135)
System.err:	at android.app.servertransaction.TransactionExecutor.execute(TransactionExecutor.java:95)
System.err:	at android.app.ActivityThread$H.handleMessage(ActivityThread.java:2308)
System.err:	at android.os.Handler.dispatchMessage(Handler.java:106)
System.err:	at android.os.Looper.loopOnce(Looper.java:201)
System.err:	at android.os.Looper.loop(Looper.java:288)
System.err:	at android.app.ActivityThread.main(ActivityThread.java:7898)
System.err:	at java.lang.reflect.Method.invoke(Native Method)
System.err:	at com.android.internal.os.RuntimeInit$MethodAndArgsCaller.run(RuntimeInit.java:548)
System.err:	at com.android.internal.os.ZygoteInit.main(ZygoteInit.java:936)
System.err: Caused by: java.lang.IllegalStateException: Secure lock screen must be enabled to create keys requiring user authentication
System.err:	at android.security.keystore2.KeyStore2ParameterUtils.getRootSid(KeyStore2ParameterUtils.java:234)
System.err:	at android.security.keystore2.KeyStore2ParameterUtils.addSids(KeyStore2ParameterUtils.java:286)
System.err:	at android.security.keystore2.KeyStore2ParameterUtils.addUserAuthArgs(KeyStore2ParameterUtils.java:329)
System.err:	at android.security.keystore2.AndroidKeyStoreKeyGeneratorSpi.engineInit(AndroidKeyStoreKeyGeneratorSpi.java:260)
System.err:	... 21 more

Otóż, na urządzeniu musi być skonfigurowana blokada ekranu, aby móc ją wykorzystać do uwierzytelniania klucza :)

Konfigurujemy blokadę, uruchamiamy ponownie aplikację i zapisujemy tajną wiadomość.
Urządzenie poprosi nas o odblokowanie klucza zarówno przy próbie zapisu jak i odczytu wiadomości.

Aplikacja intruza

Wysyłamy na urządzenie przygotowaną wcześniej konsolową aplikację intruza:

$ adb push ~/AndroidStudioProjects/KsRobberConsoleSecureFileBiometricImpl/app/release/app-release.apk /data/local/tmp/robber_v2.apk

Uruchamiamy ją z odpowiednimi uprawnieniami, w sposób znany z pierwszej części artykułu:

$ adb shell
sunfish:/ $ su
sunfish:/ # pm list packages -U pl.isec.baseapp.devicecredential
package:pl.isec.baseapp.devicecredential uid:10231
sunfish:/ # su 10231
sunfish:/ $ app_process -cp /data/local/tmp/robber_v2.apk /system/bin pl.isec.robber.console.securefilebiometricimpl.Run pl.isec.baseapp.devicecredential encrypted.txt
javax.crypto.IllegalBlockSizeException
    	at android.security.keystore2.AndroidKeyStoreCipherSpiBase.engineDoFinal(AndroidKeyStoreCipherSpiBase.java:590)
    	at javax.crypto.Cipher.doFinal(Cipher.java:2056)
    	at pl.isec.robber.console.securefilebiometricimpl.Run.main(Run.java:74)
    	at com.android.internal.os.RuntimeInit.nativeFinishInit(Native Method)
    	at com.android.internal.os.RuntimeInit.main(RuntimeInit.java:355)
Caused by: android.security.KeyStoreException: Key user not authenticated (internal Keystore code: -26 message: In KeystoreOperation::updateAad

Caused by:
	0: In update_aad: Trying to get auth tokens.
	1: In AuthInfo::get_auth_tokens.
	2: In get_auth_tokens: No operation auth token received.
	3: Error::Km(ErrorCode(-26))) (public error code: 2 internal Keystore code: -26)
    	at android.security.KeyStore2.getKeyStoreException(KeyStore2.java:369)
    	at android.security.KeyStoreOperation.handleExceptions(KeyStoreOperation.java:78)
    	at android.security.KeyStoreOperation.updateAad(KeyStoreOperation.java:100)
    	at android.security.keystore2.AndroidKeyStoreAuthenticatedAESCipherSpi$AdditionalAuthenticationDataStream.update(AndroidKeyStoreAuthenticatedAESCipherSpi.java:435)
    	at android.security.keystore2.KeyStoreCryptoOperationChunkedStreamer.update(KeyStoreCryptoOperationChunkedStreamer.java:156)
    	at android.security.keystore2.AndroidKeyStoreCipherSpiBase.engineUpdateAAD(AndroidKeyStoreCipherSpiBase.java:535)
    	at javax.crypto.Cipher.updateAAD(Cipher.java:2616)
    	at javax.crypto.Cipher.updateAAD(Cipher.java:2570)
    	at pl.isec.robber.console.securefilebiometricimpl.Run.main(Run.java:73)
    	... 2 more
sunfish:/ $

Jest sukces! :)

Udało nam się zablokować dostęp do poufnych danych przed intruzem, który posiada w systemie bardzo wysokie uprawnienia.

Uwierzytelnianie biometrią

Konfiguracja keystore z biometrią niewiele różni się od konfiguracji z blokadą ekranu, zmieniamy jedynie typ uwierzytelniania:

oraz ustawiamy wymagany dla biometrii (BiometricPrompt.PromptInfo.Builder) setNegativeButtonText(CharSequence negativeButtonText).

Bazowa aplikacja

Nazwa pakietu: pl.isec.baseapp.biometricstrong

MainActivity.java

private void initialize() throws GeneralSecurityException, IOException {
   /** Get or create key **/
   KeyGenParameterSpec masterKeySpec = createAES256GCMKeyGenParameterSpec();
   String masterKeyAlias = masterKeySpec.getKeystoreAlias();

   KeyStore ks = KeyStore.getInstance(ANDROID_KEYSTORE);
   ks.load(null);

   if (!ks.containsAlias(masterKeyAlias)) {
       KeyGenerator keyGenerator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, ANDROID_KEYSTORE);
       keyGenerator.init(masterKeySpec);
       keyGenerator.generateKey();
   }

   /** Get security level **/
   SecretKey secretKey = (SecretKey) ks.getKey(masterKeyAlias, null);
   SecretKeyFactory factory = SecretKeyFactory.getInstance(secretKey.getAlgorithm(), ANDROID_KEYSTORE);
   KeyInfo keyInfo = (KeyInfo) factory.getKeySpec(secretKey, KeyInfo.class);

   switch(keyInfo.getSecurityLevel()){
       case KeyProperties.SECURITY_LEVEL_SOFTWARE:
           mSecurityLevel = "Software"; break;
       case KeyProperties.SECURITY_LEVEL_TRUSTED_ENVIRONMENT:
           mSecurityLevel = "TEE"; break;
       case KeyProperties.SECURITY_LEVEL_STRONGBOX:
           mSecurityLevel = "StrongBox"; break;
       default:
           mSecurityLevel = "Unknown";
   }

   /** Build PromptInfo for SecureFile **/
   BiometricPrompt.PromptInfo promptInfo = new BiometricPrompt.PromptInfo.Builder()
       .setTitle("Unlock encryption key")
       .setSubtitle("Log in using your biometric")
       .setNegativeButtonText("Cancel")
       .setAllowedAuthenticators(BiometricManager.Authenticators.BIOMETRIC_STRONG)
       .build();

   /** Create instance of the SecureFile **/
   mSecureFile = new SecureFileBiometricImpl(
       new File(getFilesDir(), FILENAME),
       this,
       masterKeyAlias,
       MainActivity.this,
       promptInfo
   );
}

private static KeyGenParameterSpec createAES256GCMKeyGenParameterSpec(){
   KeyGenParameterSpec.Builder builder = new KeyGenParameterSpec.Builder(
       "file_encryption_master_key",
       KeyProperties.PURPOSE_ENCRYPT | KeyProperties.PURPOSE_DECRYPT)
       .setBlockModes(KeyProperties.BLOCK_MODE_GCM)
       .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
       .setKeySize(256)
       .setIsStrongBoxBacked(true)
       .setUserAuthenticationRequired(true)
       .setUserAuthenticationParameters(0, KeyProperties.AUTH_BIOMETRIC_STRONG)
       .setInvalidatedByBiometricEnrollment(true);

   return builder.build();
}

Instalujemy i uruchamiamy aplikację.

$ adb install ~/AndroidStudioProjects/KsBiometricStrong/app/release/app-release.apk

Podobnie jak poprzednio, ustawienie odpowiedniego typu uwierzytelniania, pociąga za sobą konsekwencje w konfiguracji urządzenia:

  • uwierzytelnianie blokadą ekranu wymusza na użytkownikowi skonfigurowanie blokady ekranu
  • uwierzytelnianie biometrią wymusza na użytkownikowi skonfigurowanie biometrii
System.err: java.security.InvalidAlgorithmParameterException: java.lang.IllegalStateException: At least one biometric must be enrolled to create keys requiring user authentication for every use
System.err:	at android.security.keystore2.AndroidKeyStoreKeyGeneratorSpi.engineInit(AndroidKeyStoreKeyGeneratorSpi.java:262)
System.err:	at android.security.keystore2.AndroidKeyStoreKeyGeneratorSpi$AES.engineInit(AndroidKeyStoreKeyGeneratorSpi.java:63)
System.err:	at javax.crypto.KeyGenerator.init(KeyGenerator.java:519)
System.err:	at javax.crypto.KeyGenerator.init(KeyGenerator.java:502)
System.err:	at pl.isec.baseapp.biometricstrong.MainActivity.initialize(MainActivity.java:123)
System.err:	at pl.isec.baseapp.biometricstrong.MainActivity.onCreate(MainActivity.java:47)
System.err:	at android.app.Activity.performCreate(Activity.java:8290)
System.err:	at android.app.Activity.performCreate(Activity.java:8269)
System.err:	at android.app.Instrumentation.callActivityOnCreate(Instrumentation.java:1384)
System.err:	at android.app.ActivityThread.performLaunchActivity(ActivityThread.java:3657)
System.err:	at android.app.ActivityThread.handleLaunchActivity(ActivityThread.java:3813)
System.err:	at android.app.servertransaction.LaunchActivityItem.execute(LaunchActivityItem.java:101)
System.err:	at android.app.servertransaction.TransactionExecutor.executeCallbacks(TransactionExecutor.java:135)
System.err:	at android.app.servertransaction.TransactionExecutor.execute(TransactionExecutor.java:95)
System.err:	at android.app.ActivityThread$H.handleMessage(ActivityThread.java:2308)
System.err:	at android.os.Handler.dispatchMessage(Handler.java:106)
System.err:	at android.os.Looper.loopOnce(Looper.java:201)
System.err:	at android.os.Looper.loop(Looper.java:288)
System.err:	at android.app.ActivityThread.main(ActivityThread.java:7898)
System.err:	at java.lang.reflect.Method.invoke(Native Method)
System.err:	at com.android.internal.os.RuntimeInit$MethodAndArgsCaller.run(RuntimeInit.java:548)
System.err:	at com.android.internal.os.ZygoteInit.main(ZygoteInit.java:936)
System.err: Caused by: java.lang.IllegalStateException: At least one biometric must be enrolled to create keys requiring user authentication for every use
System.err:	at android.security.keystore2.KeyStore2ParameterUtils.addSids(KeyStore2ParameterUtils.java:266)
System.err:	at android.security.keystore2.KeyStore2ParameterUtils.addUserAuthArgs(KeyStore2ParameterUtils.java:329)
System.err:	at android.security.keystore2.AndroidKeyStoreKeyGeneratorSpi.engineInit(AndroidKeyStoreKeyGeneratorSpi.java:260)
System.err:	... 21 more

Konfigurujemy biometrię i zapisujemy wiadomość.

Aplikacja intruza

Dla formalności, spróbujmy odczytać wiadomość aplikacją intruza:

$ adb shell
sunfish:/ $ su
sunfish:/ # pm list packages -U pl.isec.baseapp.biometricstrong  
package:pl.isec.baseapp.biometricstrong uid:10240
sunfish:/ # su 10240
sunfish:/ $ app_process -cp /data/local/tmp/robber_v2.apk /system/bin pl.isec.robber.console.securefilebiometricimpl.Run pl.isec.baseapp.biometricstrong encrypted.txt  
javax.crypto.IllegalBlockSizeException
    	at android.security.keystore2.AndroidKeyStoreCipherSpiBase.engineDoFinal(AndroidKeyStoreCipherSpiBase.java:590)
    	at javax.crypto.Cipher.doFinal(Cipher.java:2056)
    	at pl.isec.robber.console.securefilebiometricimpl.Run.main(Run.java:74)
    	at com.android.internal.os.RuntimeInit.nativeFinishInit(Native Method)
    	at com.android.internal.os.RuntimeInit.main(RuntimeInit.java:355)
Caused by: android.security.KeyStoreException: Key user not authenticated (internal Keystore code: -26 message: In KeystoreOperation::updateAad

Caused by:
	0: In update_aad: Trying to get auth tokens.
	1: In AuthInfo::get_auth_tokens.
	2: In get_auth_tokens: No operation auth token received.
	3: Error::Km(ErrorCode(-26))) (public error code: 2 internal Keystore code: -26)
    	at android.security.KeyStore2.getKeyStoreException(KeyStore2.java:369)
    	at android.security.KeyStoreOperation.handleExceptions(KeyStoreOperation.java:78)
    	at android.security.KeyStoreOperation.updateAad(KeyStoreOperation.java:100)
    	at android.security.keystore2.AndroidKeyStoreAuthenticatedAESCipherSpi$AdditionalAuthenticationDataStream.update(AndroidKeyStoreAuthenticatedAESCipherSpi.java:435)
    	at android.security.keystore2.KeyStoreCryptoOperationChunkedStreamer.update(KeyStoreCryptoOperationChunkedStreamer.java:156)
    	at android.security.keystore2.AndroidKeyStoreCipherSpiBase.engineUpdateAAD(AndroidKeyStoreCipherSpiBase.java:535)
    	at javax.crypto.Cipher.updateAAD(Cipher.java:2616)
    	at javax.crypto.Cipher.updateAAD(Cipher.java:2570)
    	at pl.isec.robber.console.securefilebiometricimpl.Run.main(Run.java:73)
    	... 2 more
sunfish:/ $

Jak można było się spodziewać, uwierzytelnianie biometrią sprawdza się równie dobrze i zapewnia skuteczną ochronę przed nieuprawnionym użyciem kluczy przechowywanych w Android Keystore.

Blokada ekranu czy biometria?

Różne sposoby uwierzytelniania mają swoje słabości, dla przykładu, PIN i hasło mogą zostać przekazane osobie trzeciej, a odblokowanie poprzez Face ID może nastąpić całkiem przypadkowo i nieświadomie.
Natomiast, z punktu widzenia dewelopera korzystającego z usługi uwierzytelniania kluczy w Android Keystore, nie ma to znaczenia, liczy się fakt, że klucz został uwierzytelniony.

O ile wymuszenie skonfigurowania blokady ekranu (PIN/hasło/wzór) przez aplikację operującą na wrażliwych danych (np. aplikacja bankowa) może być zrozumiałe, o tyle wymuszanie skonfigurowania biometrii może nie zostać pozytywnie odebrane.
Warto dać użytkownikowi wybór konfigurując aplikację do obsługi uwierzytelniania zarówno klasyczną blokadą ekranu jak i biometrią.
Przykład rozwiązania znajdziemy w oficjalnym tutorialu: https://developer.android.com/training/sign-in/biometric-auth#biometric-or-lock-screen

Deweloperzy aplikacji powinni zainteresować się również możliwością weryfikacji, czy urządzenie posiada skonfigurowaną blokadę ekranu bądź biometrię, zanim przystąpią do próby wygenerowania klucza:

Czasowe uwierzytelnianie klucza

Wspomniana już funkcja setUserAuthenticationParameters(int timeout, int type) pozwala ustawić nie tylko typ uwierzytelniania, ale także czas na jaki klucz zostanie odblokowany.
Zgodnie z dokumentacją, nasze dotychczasowe ustawienia, wymuszały odblokowanie klucza przy każdym użyciu.

https://developer.android.com/reference/android/security/keystore/KeyGenParameterSpec.Builder#setUserAuthenticationParameters(int,%20int)

timeout int: duration in seconds or 0 if user authentication must take place for every use of the key. Value is 0 or greater

Zatem, sprawdźmy czy czasowe uwierzytelnianie da intruzowi sposobność do dyskretnego użycia klucza w odblokowanym przedziale czasowym.

Bazowa aplikacja będzie niewielką modyfikacją wersji z poprzedniego testu:

  • ustawimy czas uwierzytelnienia na 30 sekund
  • pozwolimy na uwierzytelnianie zarówno biometrią jak i blokadą ekranu

Mała uwaga: Przy uwierzytelnianiu biometrią i blokadą ekranu wraz z czasowym uwierzytelnianiem, należy zrezygnować z funkcji setInvalidatedByBiometricEnrollment(boolean).

Bazowa aplikacja

Nazwa pakietu: pl.isec.baseapp.authenticationfor30s

MainActivity.java

private void initialize() throws GeneralSecurityException, IOException {
   /** Get or create key **/
   KeyGenParameterSpec masterKeySpec = createAES256GCMKeyGenParameterSpec();
   String masterKeyAlias = masterKeySpec.getKeystoreAlias();

   KeyStore ks = KeyStore.getInstance(ANDROID_KEYSTORE);
   ks.load(null);

   if (!ks.containsAlias(masterKeyAlias)) {
       KeyGenerator keyGenerator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, ANDROID_KEYSTORE);
       keyGenerator.init(masterKeySpec);
       keyGenerator.generateKey();
   }

   /** Get security level **/
   SecretKey secretKey = (SecretKey) ks.getKey(masterKeyAlias, null);
   SecretKeyFactory factory = SecretKeyFactory.getInstance(secretKey.getAlgorithm(), ANDROID_KEYSTORE);
   KeyInfo keyInfo = (KeyInfo) factory.getKeySpec(secretKey, KeyInfo.class);

   switch(keyInfo.getSecurityLevel()){
       case KeyProperties.SECURITY_LEVEL_SOFTWARE:
           mSecurityLevel = "Software"; break;
       case KeyProperties.SECURITY_LEVEL_TRUSTED_ENVIRONMENT:
           mSecurityLevel = "TEE"; break;
       case KeyProperties.SECURITY_LEVEL_STRONGBOX:
           mSecurityLevel = "StrongBox"; break;
       default:
           mSecurityLevel = "Unknown";
   }

   /** Build PromptInfo for SecureFile **/
   BiometricPrompt.PromptInfo promptInfo = new BiometricPrompt.PromptInfo.Builder()
       .setTitle("Unlock encryption key")
       .setSubtitle("Log in using your biometric or device credential")
       .setAllowedAuthenticators(BiometricManager.Authenticators.DEVICE_CREDENTIAL | BiometricManager.Authenticators.BIOMETRIC_STRONG)
       .build();

   /** Create instance of the SecureFile **/
   mSecureFile = new SecureFileBiometricImpl(
       new File(getFilesDir(), FILENAME),
       this,
       masterKeyAlias,
       MainActivity.this,
       promptInfo
   );
}

private static KeyGenParameterSpec createAES256GCMKeyGenParameterSpec(){
   KeyGenParameterSpec.Builder builder = new KeyGenParameterSpec.Builder(
       "file_encryption_master_key",
       KeyProperties.PURPOSE_ENCRYPT | KeyProperties.PURPOSE_DECRYPT)
       .setBlockModes(KeyProperties.BLOCK_MODE_GCM)
       .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
       .setKeySize(256)
       .setIsStrongBoxBacked(true)
       .setUserAuthenticationRequired(true)
       .setUserAuthenticationParameters(30, KeyProperties.AUTH_DEVICE_CREDENTIAL | KeyProperties.AUTH_BIOMETRIC_STRONG);

   return builder.build();
}

Instalujemy, uruchamiamy aplikację i zapisujemy kolejną wiadomość.

$ adb install ~/AndroidStudioProjects/KsAuthenticationFor30s/app/release/app-release.apk

Aplikacja intruza

Uruchamiamy test w przeciągu 30 sekund od pomyślnego uwierzytelnienia:

$ adb shell
sunfish:/ $ su
sunfish:/ # pm list packages -U pl.isec.baseapp.authenticationfor30s
package:pl.isec.baseapp.authenticationfor30s uid:10254
sunfish:/ # su 10254
sunfish:/ $ app_process -cp /data/local/tmp/robber_v2.apk /system/bin pl.isec.robber.console.securefilebiometricimpl.Run pl.isec.baseapp.authenticationfor30s encrypted.txt
Okno czasowe bez zabezpieczeń antywłamaniowych :)
sunfish:/ $

Po przekroczeniu 30 sekund:

sunfish:/ $ app_process -cp /data/local/tmp/robber_v2.apk /system/bin pl.isec.robber.console.securefilebiometricimpl.Run pl.isec.baseapp.authenticationfor30s encrypted.txt                                                                 	<
android.security.keystore.UserNotAuthenticatedException: User not authenticated
    	at android.security.keystore2.KeyStoreCryptoOperationUtils.getInvalidKeyException(KeyStoreCryptoOperationUtils.java:128)
    	at android.security.keystore2.KeyStoreCryptoOperationUtils.getExceptionForCipherInit(KeyStoreCryptoOperationUtils.java:154)
    	at android.security.keystore2.AndroidKeyStoreCipherSpiBase.ensureKeystoreOperationInitialized(AndroidKeyStoreCipherSpiBase.java:339)
    	at android.security.keystore2.AndroidKeyStoreCipherSpiBase.engineInit(AndroidKeyStoreCipherSpiBase.java:234)
    	at javax.crypto.Cipher.tryTransformWithProvider(Cipher.java:2981)
    	at javax.crypto.Cipher.tryCombinations(Cipher.java:2892)
    	at javax.crypto.Cipher$SpiAndProviderUpdater.updateAndGetSpiAndProvider(Cipher.java:2797)
    	at javax.crypto.Cipher.chooseProvider(Cipher.java:774)
    	at javax.crypto.Cipher.init(Cipher.java:1289)
    	at javax.crypto.Cipher.init(Cipher.java:1224)
    	at pl.isec.robber.console.securefilebiometricimpl.Run.main(Run.java:72)
    	at com.android.internal.os.RuntimeInit.nativeFinishInit(Native Method)
    	at com.android.internal.os.RuntimeInit.main(RuntimeInit.java:355)

Wykorzystując krótki okres czasu, udało nam się odczytać wiadomość za pomocą aplikacji nieprzystosowanej do uwierzytelniania kluczy!

Zadziałało to, gdyż uwierzytelnianie w Android Keystore zaimplementowane jest na niskim poziomie, w systemowej usłudze.
Z tego powodu, żadne modyfikacje naszej aplikacji nie pozwolą obejść mechanizmu uwierzytelniania.
Z drugiej strony, raz odblokowany klucz z niezerowym czasem podtrzymywania autoryzacji, pozostaje dostępny na określony czas, jak gdyby nie był chroniony uwierzytelnianiem.

Drobny błąd i jego fatalne konsekwencje

Korzystając z aplikacji, dla której zaimplementowaliśmy 30 sekundowe okno czasowe, możemy odnieść wrażenie, że aplikacja i tak wymaga uwierzytelnienia przy każdym użyciu klucza.

Czy coś poszło nie tak?

Mając na uwadze powyższe obserwacje oraz wyniki testów z aplikacją intruza, dochodzimy do prostego wniosku, że ta potrzeba każdorazowego uwierzytelnienia, wynika jedynie z mało profesjonalnej implementacji interfejsu aplikacji, która nie sprawdza, czy wykorzystanie klucza wymaga podjęcia dodatkowych akcji.
Ponadto, dowiedzieliśmy się, że Android Keystore nie zwróci żadnego błędu przy uwierzytelnianiu klucza, który tego uwierzytelniania nie wymaga.

Tym sposobem dochodzimy do ciekawego przypadku, w którym aplikacja będzie wymagała i obsługiwała uwierzytelnianie przy każdym użyciu klucza, jednak w implementacji znajdzie się drobny błąd przy jego generowaniu, polegający na pominięciu funkcji setUserAuthenticationRequired(true).

Bazowa aplikacja

Nazwa pakietu: pl.isec.baseapp.invalidauthentication

MainActivity.java

private void initialize() throws GeneralSecurityException, IOException {
   /** Get or create key **/
   KeyGenParameterSpec masterKeySpec = createAES256GCMKeyGenParameterSpec();
   String masterKeyAlias = masterKeySpec.getKeystoreAlias();

   KeyStore ks = KeyStore.getInstance(ANDROID_KEYSTORE);
   ks.load(null);

   if (!ks.containsAlias(masterKeyAlias)) {
       KeyGenerator keyGenerator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, ANDROID_KEYSTORE);
       keyGenerator.init(masterKeySpec);
       keyGenerator.generateKey();
   }

   /** Get security level **/
   SecretKey secretKey = (SecretKey) ks.getKey(masterKeyAlias, null);
   SecretKeyFactory factory = SecretKeyFactory.getInstance(secretKey.getAlgorithm(), ANDROID_KEYSTORE);
   KeyInfo keyInfo = (KeyInfo) factory.getKeySpec(secretKey, KeyInfo.class);

   switch(keyInfo.getSecurityLevel()){
       case KeyProperties.SECURITY_LEVEL_SOFTWARE:
           mSecurityLevel = "Software"; break;
       case KeyProperties.SECURITY_LEVEL_TRUSTED_ENVIRONMENT:
           mSecurityLevel = "TEE"; break;
       case KeyProperties.SECURITY_LEVEL_STRONGBOX:
           mSecurityLevel = "StrongBox"; break;
       default:
           mSecurityLevel = "Unknown";
   }

   /** Build PromptInfo for SecureFile **/
   BiometricPrompt.PromptInfo promptInfo = new BiometricPrompt.PromptInfo.Builder()
           .setTitle("Unlock encryption key")
           .setSubtitle("Log in using your biometric or device credential")
           .setAllowedAuthenticators(BiometricManager.Authenticators.DEVICE_CREDENTIAL | BiometricManager.Authenticators.BIOMETRIC_STRONG)
           .build();

   /** Create instance of the SecureFile **/
   mSecureFile = new SecureFileBiometricImpl(
           new File(getFilesDir(), FILENAME),
           this,
           masterKeyAlias,
           MainActivity.this,
           promptInfo
   );
}

private static KeyGenParameterSpec createAES256GCMKeyGenParameterSpec(){
   KeyGenParameterSpec.Builder builder = new KeyGenParameterSpec.Builder(
           "file_encryption_master_key",
           KeyProperties.PURPOSE_ENCRYPT | KeyProperties.PURPOSE_DECRYPT)
           .setBlockModes(KeyProperties.BLOCK_MODE_GCM)
           .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
           .setKeySize(256)
           .setIsStrongBoxBacked(true)
           //.setUserAuthenticationRequired(true) // oh no :)
           .setUserAuthenticationParameters(0, KeyProperties.AUTH_DEVICE_CREDENTIAL | KeyProperties.AUTH_BIOMETRIC_STRONG);

   return builder.build();
}

Instalujemy aplikację, uruchamiamy i zapisujemy wiadomość.

$ adb install ~/AndroidStudioProjects/KsInvalidAuthentication/app/release/app-release.apk

Interfejs aplikacji wymaga uwierzytelniania zarówno przy zapisie jak i odczycie wiadomości.
Anulowanie uwierzytelniania kończy się stosownym błędem.

Aplikacja intruza

Sprawdźmy zatem, czy drobny błąd przy generowaniu klucza może zburzyć wysiłek włożony w obsługę uwierzytelniania.

$ adb shell
sunfish:/ $ su
sunfish:/ # pm list packages -U pl.isec.baseapp.invalidauthentication
package:pl.isec.baseapp.invalidauthentication uid:10256
sunfish:/ # su 10256
sunfish:/ $ app_process -cp /data/local/tmp/robber_v2.apk /system/bin pl.isec.robber.console.securefilebiometricimpl.Run pl.isec.baseapp.invalidauthentication encrypted.txt
Czy to jest bezpieczne? :)
sunfish:/ $

A jednak! Wszystko co zaimplementowane jest w zwykłej aplikacji da się w pewien sposób obejść, a dla usługi Android Keystore liczą się jedynie twardo zdefiniowane wymagania :)

Wnioski

Potwierdziliśmy, że wymaganie uwierzytelnienia klucza przechowywanego w Android Keystore, potrafi zabezpieczyć poufne dane przed intruzem posiadającym bardzo wysokie uprawnienia w systemie. Swego rodzaju “minusem” tego rozwiązania, jest wymuszenie na użytkowniku skonfigurowania odpowiednich zabezpieczeń urządzenia.
Dowiedliśmy, że udogodnienie dla deweloperów i użytkowników, jakimi są klucze uwierzytelniane na zadany czas, wprowadza pewne słabości, w postaci krótkich okien czasowych, w których potencjalny intruz może skorzystać z materiału szyfrującego.

Ostatni przykład pokazał nam z jak wielką uwagą należy definiować wymagania podczas procesu generowania klucza, gdyż prosty błąd może sprawić, że aplikacja obsługująca uwierzytelnianie biometrią i blokadą ekranu, w rzeczywistości bezpieczeństwo danych będzie opierać na kluczach niechronionych uwierzytelnianiem.

Sekcja linków

Bazowa aplikacja

Uwierzytelnianie blokadą ekranu

Uwierzytelnianie biometrią

Czasowe uwierzytelnianie klucza

Drobny błąd

Aplikacja intruza

Great! You've successfully subscribed.
Great! Next, complete checkout for full access.
Welcome back! You've successfully signed in.
Success! Your account is fully activated, you now have access to all content.