BiometricKeyStore.kt

6.7 KB · raw

1package com.peachpasswords.ui.lock23import android.content.Context4import android.os.Build5import android.security.keystore.KeyGenParameterSpec6import android.security.keystore.KeyInfo7import android.security.keystore.KeyPermanentlyInvalidatedException8import android.security.keystore.KeyProperties9import android.security.keystore.StrongBoxUnavailableException10import java.security.KeyStore11import java.security.GeneralSecurityException12import java.util.Base6413import javax.crypto.Cipher14import javax.crypto.KeyGenerator15import javax.crypto.SecretKey16import javax.crypto.SecretKeyFactory17import javax.crypto.spec.GCMParameterSpec1819/**20 * Stores the vault's AES key wrapped under a hardware-backed, biometric-gated21 * Android KeyStore key, so the lock screen can unlock without the master password.22 *23 * The KeyStore key requires user (biometric) authentication for every use, so both24 * wrapping (on enable) and unwrapping (on unlock) must happen inside a25 * [androidx.biometric.BiometricPrompt] `CryptoObject`. The wrapped blob + IV are26 * kept in plain prefs — they are AES-256-GCM ciphertext under a key that never27 * leaves the secure hardware, so the prefs themselves carry no secret.28 *29 * Enrolling a new biometric invalidates the key ([setInvalidatedByBiometricEnrollment]);30 * the resulting [KeyPermanentlyInvalidatedException] is caught and the escrow cleared,31 * so the user simply falls back to the master password.32 */33object BiometricKeyStore {34    private const val KEY_ALIAS = "peach_bio_vault_key"35    private const val KEYSTORE = "AndroidKeyStore"36    private const val TRANSFORM = "AES/GCM/NoPadding"37    private const val PREFS = "peach_bio_prefs"38    private const val K_WRAPPED = "wrapped"39    private const val K_IV = "iv"40    private const val GCM_TAG_BITS = 1284142    private fun prefs(context: Context) =43        context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)4445    fun hasWrappedKey(context: Context): Boolean =46        prefs(context).contains(K_WRAPPED) && prefs(context).contains(K_IV) &&47            (runCatching { existingKey()?.let(::isHardwareBacked) == true }.getOrDefault(false))4849    fun clear(context: Context) {50        prefs(context).edit().remove(K_WRAPPED).remove(K_IV).apply()51        runCatching {52            KeyStore.getInstance(KEYSTORE).apply { load(null) }.deleteEntry(KEY_ALIAS)53        }54    }5556    /** Cipher to wrap the vault key on enable (fresh random IV). Drive via CryptoObject. */57    fun encryptCipher(): Cipher {58        val key = getOrCreateKey()59        return Cipher.getInstance(TRANSFORM).apply { init(Cipher.ENCRYPT_MODE, key) }60    }6162    /**63     * Cipher to unwrap on unlock, initialised with the stored IV. Returns null when64     * no escrow exists or the key was invalidated by a new biometric enrolment (the65     * escrow is cleared in that case so the caller falls back to the password).66     */67    fun decryptCipher(context: Context): Cipher? {68        val iv = loadIv(context) ?: return null69        return try {70            val key = existingKey()?.takeIf(::isHardwareBacked) ?: return null71            Cipher.getInstance(TRANSFORM).apply { init(Cipher.DECRYPT_MODE, key, GCMParameterSpec(GCM_TAG_BITS, iv)) }72        } catch (_: KeyPermanentlyInvalidatedException) {73            clear(context)74            null75        } catch (_: Exception) {76            null77        }78    }7980    /** Wrap [vaultKey] with the authenticated [cipher] and persist it. */81    fun wrapAndStore(context: Context, cipher: Cipher, vaultKey: ByteArray) {82        val wrapped = cipher.doFinal(vaultKey)83        prefs(context).edit()84            .putString(K_WRAPPED, b64(wrapped))85            .putString(K_IV, b64(cipher.iv))86            .apply()87    }8889    /** Unwrap the stored vault key with the authenticated [cipher]. */90    fun unwrap(context: Context, cipher: Cipher): ByteArray? {91        val wrapped = prefs(context).getString(K_WRAPPED, null)?.let(::unb64) ?: return null92        return runCatching { cipher.doFinal(wrapped) }.getOrNull()93    }9495    private fun loadIv(context: Context): ByteArray? =96        prefs(context).getString(K_IV, null)?.let(::unb64)9798    @Suppress("NewApi")99    private fun getOrCreateKey(): SecretKey {100        existingKey()?.let { key ->101            if (isHardwareBacked(key)) return key102            deleteKey()103        }104        val builder = KeyGenParameterSpec.Builder(105            KEY_ALIAS,106            KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT,107        )108            .setBlockModes(KeyProperties.BLOCK_MODE_GCM)109            .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)110            .setKeySize(256)111            .setUserAuthenticationRequired(true)112            .setInvalidatedByBiometricEnrollment(true)113        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {114            builder.setUserAuthenticationParameters(0, KeyProperties.AUTH_BIOMETRIC_STRONG)115        }116        val key = try {117            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {118                builder.setIsStrongBoxBacked(true)119            }120            generateKey(builder)121        } catch (_: StrongBoxUnavailableException) {122            generateKey(builder, strongBox = false)123        }124        if (!isHardwareBacked(key)) {125            deleteKey()126            throw GeneralSecurityException("biometric_key_not_hardware_backed")127        }128        return key129    }130131    private fun generateKey(builder: KeyGenParameterSpec.Builder, strongBox: Boolean = true): SecretKey {132        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P && !strongBox) {133            builder.setIsStrongBoxBacked(false)134        }135        val gen = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, KEYSTORE)136        gen.init(builder.build())137        return gen.generateKey()138    }139140    private fun existingKey(): SecretKey? {141        val ks = KeyStore.getInstance(KEYSTORE).apply { load(null) }142        return (ks.getEntry(KEY_ALIAS, null) as? KeyStore.SecretKeyEntry)?.secretKey143    }144145    private fun isHardwareBacked(key: SecretKey): Boolean = runCatching {146        val info = SecretKeyFactory.getInstance(key.algorithm, KEYSTORE)147            .getKeySpec(key, KeyInfo::class.java) as KeyInfo148        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {149            info.securityLevel == KeyProperties.SECURITY_LEVEL_TRUSTED_ENVIRONMENT ||150                info.securityLevel == KeyProperties.SECURITY_LEVEL_STRONGBOX151        } else {152            @Suppress("DEPRECATION")153            info.isInsideSecureHardware154        }155    }.getOrDefault(false)156157    private fun deleteKey() {158        runCatching { KeyStore.getInstance(KEYSTORE).apply { load(null) }.deleteEntry(KEY_ALIAS) }159    }160161    private fun b64(b: ByteArray) = Base64.getEncoder().encodeToString(b)162    private fun unb64(s: String) = Base64.getDecoder().decode(s)163}