PinUnlockStore.kt

17.0 KB · raw

1package com.peachpasswords.vault23import android.content.Context4import android.content.pm.PackageManager5import android.os.Build6import android.security.keystore.KeyGenParameterSpec7import android.security.keystore.KeyInfo8import android.security.keystore.KeyPermanentlyInvalidatedException9import android.security.keystore.KeyProperties10import android.security.keystore.StrongBoxUnavailableException11import dagger.hilt.android.qualifiers.ApplicationContext12import java.security.KeyStore13import java.security.MessageDigest14import java.util.Base6415import java.util.UUID16import javax.crypto.Cipher17import javax.crypto.KeyGenerator18import javax.crypto.Mac19import javax.crypto.SecretKey20import javax.crypto.SecretKeyFactory21import javax.crypto.spec.GCMParameterSpec22import javax.inject.Inject23import javax.inject.Singleton2425internal const val PIN_MIN_LENGTH = 426internal const val PIN_MAX_LENGTH = 122728internal fun pinValidationError(pin: String): String? = when {29    pin.length !in PIN_MIN_LENGTH..PIN_MAX_LENGTH -> "PIN must be 4–12 digits"30    pin.any { it !in '0'..'9' } -> "PIN must contain digits only"31    else -> null32}3334/**35 * Device-local PIN gate for a vault key held by Android Keystore.36 *37 * The PIN is never used to encrypt the vault key. A hardware-backed AES key38 * encrypts the vault key directly, while a separate hardware-backed HMAC key39 * verifies Argon2id(PIN). Every attempt first consumes one single-use HMAC gate40 * in Keystore. The salt, verifier, IV, and ciphertext in SharedPreferences are41 * therefore useless for offline guessing without the device's Keystore keys.42 */43@Singleton44class PinUnlockStore @Inject constructor(45    @ApplicationContext context: Context,46) {47    private val appContext = context.applicationContext48    private val prefs = appContext.getSharedPreferences(PREFS, Context.MODE_PRIVATE)49    private var supported: Boolean? = null5051    init {52        // The v1 wrapper was directly brute-forceable from a copied prefs file.53        // Remove it as soon as the security-remediated app opens the store; users54        // can still unlock with the master password and configure the new format.55        clearLegacyConfiguration()56        when (prefs.getInt(KEY_FORMAT, 0)) {57            FORMAT_VERSION -> Unit58            0 -> clearKnownKeystoreAliases()59            else -> clear()60        }61    }6263    /**64     * Whether this device can host a PIN at all. Measured by probing the Keystore65     * rather than by reading FEATURE_KEYSTORE_LIMITED_USE_KEY / SINGLE_USE_KEY:66     * a Galaxy Z Fold 6 on SDK 36 enforces max-usage-1 HMAC keys in both67     * StrongBox and TEE while declaring neither feature, so the flags would68     * disable PIN unlock on hardware that supports it outright.69     */70    fun isSupported(): Boolean {71        supported?.let { return it }72        val measured = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && probeLimitedUseEnforcement()73        supported = measured74        return measured75    }7677    fun isConfigured(): Boolean =78        prefs.getInt(KEY_FORMAT, 0) == FORMAT_VERSION &&79            prefs.contains(KEY_SALT) &&80            prefs.contains(KEY_VERIFIER) &&81            prefs.contains(KEY_WRAPPED_KEY) &&82            prefs.contains(KEY_WRAPPED_IV) &&83            existingSecretKey(VAULT_ALIAS) != null &&84            existingSecretKey(VERIFIER_ALIAS) != null8586    fun isPinPreferred(): Boolean = prefs.getBoolean(KEY_PREFERRED, false)8788    fun markPinUsed() {89        prefs.edit().putBoolean(KEY_PREFERRED, true).apply()90    }9192    fun markPasswordUsed() {93        prefs.edit().putBoolean(KEY_PREFERRED, false).apply()94    }9596    fun configure(pin: String, vaultKey: ByteArray): Boolean {97        if (!isSupported() || pinValidationError(pin) != null || vaultKey.size != VaultCrypto.KEY_SIZE) return false98        clear()99100        var salt: ByteArray? = null101        var pinKey: ByteArray? = null102        var verifier: ByteArray? = null103        var wrapped: ByteArray? = null104        val createdAliases = mutableListOf<String>()105        return try {106            val vaultKeystoreKey = generateAesKey(VAULT_ALIAS, createdAliases)107            val verifierKeystoreKey = generateHmacKey(VERIFIER_ALIAS, limitedUse = false, createdAliases)108            salt = VaultCrypto.generateSalt()109            pinKey = VaultCrypto.deriveKey(pin, salt)110            verifier = hmac(verifierKeystoreKey, pinKey)111112            val cipher = Cipher.getInstance(TRANSFORM).apply { init(Cipher.ENCRYPT_MODE, vaultKeystoreKey) }113            wrapped = cipher.doFinal(vaultKey)114            val attemptAliases = createAttemptGates(createdAliases)115116            val saved = prefs.edit()117                .putInt(KEY_FORMAT, FORMAT_VERSION)118                .putString(KEY_SALT, b64(salt!!))119                .putString(KEY_VERIFIER, b64(verifier!!))120                .putString(KEY_WRAPPED_KEY, b64(wrapped!!))121                .putString(KEY_WRAPPED_IV, b64(cipher.iv))122                .putString(KEY_ATTEMPT_ALIASES, attemptAliases.joinToString(DELIMITER))123                .putBoolean(KEY_PREFERRED, false)124                .commit()125            if (!saved) throw java.io.IOException("pin_storage_commit_failed")126            true127        } catch (_: Exception) {128            createdAliases.forEach(::deleteKey)129            clear()130            false131        } finally {132            salt?.let(VaultCrypto::zero)133            pinKey?.let(VaultCrypto::zero)134            verifier?.let(VaultCrypto::zero)135            wrapped?.let(VaultCrypto::zero)136        }137    }138139    /**140     * Returns the vault key only after a hardware gate has been consumed and the141     * hardware HMAC verifier accepts the Argon2id-derived PIN key.142     */143    fun unlockVaultKey(pin: String): ByteArray? {144        if (pinValidationError(pin) != null || !isConfigured() || !consumeAttemptGate()) return null145        var salt: ByteArray? = null146        var expected: ByteArray? = null147        var pinKey: ByteArray? = null148        var actual: ByteArray? = null149        var wrapped: ByteArray? = null150        var iv: ByteArray? = null151        return try {152            salt = prefs.getString(KEY_SALT, null)?.let(::decode) ?: return null153            expected = prefs.getString(KEY_VERIFIER, null)?.let(::decode) ?: return null154            val verifierKey = existingSecretKey(VERIFIER_ALIAS) ?: return null155            pinKey = VaultCrypto.deriveKey(pin, salt!!)156            actual = hmac(verifierKey, pinKey)157            if (!MessageDigest.isEqual(expected, actual)) return null158159            wrapped = prefs.getString(KEY_WRAPPED_KEY, null)?.let(::decode) ?: return null160            iv = prefs.getString(KEY_WRAPPED_IV, null)?.let(::decode) ?: return null161            val vaultKey = Cipher.getInstance(TRANSFORM).run {162                init(Cipher.DECRYPT_MODE, existingSecretKey(VAULT_ALIAS) ?: return null, GCMParameterSpec(TAG_BITS, iv!!))163                doFinal(wrapped!!)164            }165            // A successful PIN proves possession of the PIN; replenish the166            // hardware attempt budget for the next lock/unlock cycle.167            replenishAttemptGates()168            vaultKey169        } catch (_: Exception) {170            null171        } finally {172            salt?.let(VaultCrypto::zero)173            expected?.let(VaultCrypto::zero)174            pinKey?.let(VaultCrypto::zero)175            actual?.let(VaultCrypto::zero)176            wrapped?.let(VaultCrypto::zero)177            iv?.let(VaultCrypto::zero)178        }179    }180181    fun areAttemptsExhausted(): Boolean = isConfigured() &&182        loadAttemptAliases().none { existingSecretKey(it) != null }183184    /**185     * Prove this device actually enforces a one-use hardware HMAC key instead of186     * trusting its feature flag: the first MAC must succeed and the same key must187     * then be unavailable or reject a second MAC. Generation also requires a188     * hardware-backed key, so a software-only provider fails the probe.189     */190    internal fun probeLimitedUseEnforcement(): Boolean {191        val alias = "$PROBE_ALIAS_PREFIX${UUID.randomUUID()}"192        val createdAliases = mutableListOf<String>()193        return try {194            val key = generateHmacKey(alias, limitedUse = true, createdAliases)195            hmac(key, ATTEMPT_CHALLENGE).let(VaultCrypto::zero)196            val remainingKey = existingSecretKey(alias) ?: return true197            val secondUseSucceeded = runCatching {198                hmac(remainingKey, ATTEMPT_CHALLENGE).let(VaultCrypto::zero)199            }.isSuccess200            !secondUseSucceeded201        } catch (_: Exception) {202            false203        } finally {204            deleteKey(alias)205        }206    }207208    /** Regenerate the hardware attempt budget after a master-password unlock. */209    fun replenishAttemptGates() {210        if (!isConfigured()) return211        val oldAliases = loadAttemptAliases()212        val newAliases = mutableListOf<String>()213        try {214            val attemptAliases = createAttemptGates(newAliases)215            if (!prefs.edit().putString(KEY_ATTEMPT_ALIASES, attemptAliases.joinToString(DELIMITER)).commit()) {216                throw java.io.IOException("pin_attempt_gate_commit_failed")217            }218            oldAliases.forEach(::deleteKey)219        } catch (_: Exception) {220            newAliases.forEach(::deleteKey)221        }222    }223224    fun clear() {225        loadAttemptAliases().forEach(::deleteKey)226        listOf(VAULT_ALIAS, VERIFIER_ALIAS).forEach(::deleteKey)227        clearLegacyConfiguration()228        prefs.edit()229            .remove(KEY_FORMAT)230            .remove(KEY_SALT)231            .remove(KEY_VERIFIER)232            .remove(KEY_WRAPPED_KEY)233            .remove(KEY_WRAPPED_IV)234            .remove(KEY_ATTEMPT_ALIASES)235            .remove(KEY_PREFERRED)236            .commit()237    }238239    private fun consumeAttemptGate(): Boolean {240        val aliases = loadAttemptAliases()241        for (alias in aliases) {242            val key = existingSecretKey(alias) ?: continue243            try {244                // The output is intentionally discarded; the operation exists to245                // consume the one-use hardware authorization before KDF work.246                hmac(key, ATTEMPT_CHALLENGE)247                deleteKey(alias)248                prefs.edit().putString(249                    KEY_ATTEMPT_ALIASES,250                    aliases.filterNot { it == alias }.joinToString(DELIMITER),251                ).commit()252                return true253            } catch (_: KeyPermanentlyInvalidatedException) {254                deleteKey(alias)255            } catch (_: Exception) {256                deleteKey(alias)257            }258        }259        return false260    }261262    private fun createAttemptGates(createdAliases: MutableList<String>): List<String> {263        repeat(ATTEMPT_GATE_COUNT) {264            val alias = "$ATTEMPT_ALIAS_PREFIX${UUID.randomUUID()}"265            generateHmacKey(alias, limitedUse = true, createdAliases)266        }267        val aliases = createdAliases.filter { it.startsWith(ATTEMPT_ALIAS_PREFIX) }268        if (aliases.size != ATTEMPT_GATE_COUNT) throw java.io.IOException("pin_attempt_gate_count")269        return aliases270    }271272    @Suppress("NewApi")273    private fun generateAesKey(alias: String, createdAliases: MutableList<String>): SecretKey {274        existingSecretKey(alias)?.let { key ->275            if (isHardwareBacked(key)) return key276            deleteKey(alias)277        }278        val strongBoxPreferred = Build.VERSION.SDK_INT >= Build.VERSION_CODES.P &&279            appContext.packageManager.hasSystemFeature(PackageManager.FEATURE_STRONGBOX_KEYSTORE)280        val key = try {281            generateAesKey(alias, strongBoxPreferred)282        } catch (_: StrongBoxUnavailableException) {283            deleteKey(alias)284            generateAesKey(alias, false)285        }286        if (!isHardwareBacked(key)) {287            deleteKey(alias)288            throw java.security.GeneralSecurityException("pin_key_not_hardware_backed")289        }290        createdAliases += alias291        return key292    }293294    @Suppress("NewApi")295    private fun generateHmacKey(alias: String, limitedUse: Boolean, createdAliases: MutableList<String>): SecretKey {296        val strongBoxPreferred = Build.VERSION.SDK_INT >= Build.VERSION_CODES.P &&297            appContext.packageManager.hasSystemFeature(PackageManager.FEATURE_STRONGBOX_KEYSTORE)298        val key = try {299            generateHmacKey(alias, limitedUse, strongBoxPreferred)300        } catch (_: StrongBoxUnavailableException) {301            deleteKey(alias)302            generateHmacKey(alias, limitedUse, false)303        }304        if (!isHardwareBacked(key)) {305            deleteKey(alias)306            throw java.security.GeneralSecurityException("pin_key_not_hardware_backed")307        }308        createdAliases += alias309        return key310    }311312    private fun generateAesKey(alias: String, strongBox: Boolean): SecretKey {313        val builder = KeyGenParameterSpec.Builder(314            alias,315            KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT,316        )317            .setBlockModes(KeyProperties.BLOCK_MODE_GCM)318            .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)319            .setKeySize(256)320        if (strongBox && Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) builder.setIsStrongBoxBacked(true)321        val generator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, KEYSTORE)322        generator.init(builder.build())323        return generator.generateKey()324    }325326    private fun generateHmacKey(alias: String, limitedUse: Boolean, strongBox: Boolean): SecretKey {327        val builder = KeyGenParameterSpec.Builder(alias, KeyProperties.PURPOSE_SIGN)328            .setDigests(KeyProperties.DIGEST_SHA256)329            .setKeySize(256)330        if (limitedUse && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) builder.setMaxUsageCount(1)331        if (strongBox && Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) builder.setIsStrongBoxBacked(true)332        val generator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_HMAC_SHA256, KEYSTORE)333        generator.init(builder.build())334        return generator.generateKey()335    }336337    private fun hmac(key: SecretKey, input: ByteArray): ByteArray =338        Mac.getInstance("HmacSHA256").apply { init(key) }.doFinal(input)339340    private fun existingSecretKey(alias: String): SecretKey? = runCatching {341        val keyStore = KeyStore.getInstance(KEYSTORE).apply { load(null) }342        (keyStore.getEntry(alias, null) as? KeyStore.SecretKeyEntry)?.secretKey343    }.getOrNull()344345    private fun isHardwareBacked(key: SecretKey): Boolean = runCatching {346        val factory = SecretKeyFactory.getInstance(key.algorithm, KEYSTORE)347        val info = factory.getKeySpec(key, KeyInfo::class.java) as KeyInfo348        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {349            info.securityLevel == KeyProperties.SECURITY_LEVEL_TRUSTED_ENVIRONMENT ||350                info.securityLevel == KeyProperties.SECURITY_LEVEL_STRONGBOX351        } else {352            @Suppress("DEPRECATION")353            info.isInsideSecureHardware354        }355    }.getOrDefault(false)356357    private fun loadAttemptAliases(): List<String> =358        prefs.getString(KEY_ATTEMPT_ALIASES, null)359            ?.split(DELIMITER)360            ?.filter { it.startsWith(ATTEMPT_ALIAS_PREFIX) }361            ?: emptyList()362363    private fun deleteKey(alias: String) {364        runCatching { KeyStore.getInstance(KEYSTORE).apply { load(null) }.deleteEntry(alias) }365    }366367    private fun clearKnownKeystoreAliases() {368        runCatching {369            KeyStore.getInstance(KEYSTORE).apply { load(null) }.aliases().toList()370                .filter {371                    it == VAULT_ALIAS || it == VERIFIER_ALIAS ||372                        it.startsWith(ATTEMPT_ALIAS_PREFIX) || it.startsWith(PROBE_ALIAS_PREFIX)373                }374                .forEach(::deleteKey)375        }376    }377378    private fun clearLegacyConfiguration() {379        prefs.edit()380            .remove("peach_pin_salt")381            .remove("peach_pin_verifier")382            .remove("peach_pin_wrapped_key")383            .remove("peach_pin_preferred")384            .commit()385    }386387    private fun b64(bytes: ByteArray): String = Base64.getEncoder().withoutPadding().encodeToString(bytes)388    private fun decode(value: String): ByteArray = Base64.getDecoder().decode(value)389390    private companion object {391        const val FORMAT_VERSION = 3392        const val PREFS = "peach_vault_store"393        const val KEY_FORMAT = "peach_pin_format"394        const val KEY_SALT = "peach_pin_salt_v2"395        const val KEY_VERIFIER = "peach_pin_verifier_v2"396        const val KEY_WRAPPED_KEY = "peach_pin_wrapped_key_v2"397        const val KEY_WRAPPED_IV = "peach_pin_wrapped_iv_v2"398        const val KEY_ATTEMPT_ALIASES = "peach_pin_attempt_aliases_v2"399        const val KEY_PREFERRED = "peach_pin_preferred_v2"400        const val KEYSTORE = "AndroidKeyStore"401        const val VAULT_ALIAS = "peach_pin_vault_key_v2"402        const val VERIFIER_ALIAS = "peach_pin_verifier_key_v2"403        const val ATTEMPT_ALIAS_PREFIX = "peach_pin_attempt_v2_"404        const val PROBE_ALIAS_PREFIX = "peach_pin_probe_v3_"405        const val ATTEMPT_GATE_COUNT = 5406        const val TRANSFORM = "AES/GCM/NoPadding"407        const val TAG_BITS = 128408        const val DELIMITER = ","409        val ATTEMPT_CHALLENGE = "peach_pin_attempt_gate_v2".toByteArray(Charsets.UTF_8)410    }411}