PinUnlockStore.kt

14.6 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)4950    init {51        // The v1 wrapper was directly brute-forceable from a copied prefs file.52        // Remove it as soon as the security-remediated app opens the store; users53        // can still unlock with the master password and configure the new format.54        clearLegacyConfiguration()55    }5657    /** New PIN setup is safe only where Android reports hardware limited-use keys. */58    fun isSupported(): Boolean = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S &&59        (appContext.packageManager.hasSystemFeature(PackageManager.FEATURE_KEYSTORE_LIMITED_USE_KEY) ||60            appContext.packageManager.hasSystemFeature(PackageManager.FEATURE_KEYSTORE_SINGLE_USE_KEY))6162    fun isConfigured(): Boolean =63        prefs.getInt(KEY_FORMAT, 0) == FORMAT_VERSION &&64            prefs.contains(KEY_SALT) &&65            prefs.contains(KEY_VERIFIER) &&66            prefs.contains(KEY_WRAPPED_KEY) &&67            prefs.contains(KEY_WRAPPED_IV)6869    fun isPinPreferred(): Boolean = prefs.getBoolean(KEY_PREFERRED, false)7071    fun markPinUsed() {72        prefs.edit().putBoolean(KEY_PREFERRED, true).apply()73    }7475    fun markPasswordUsed() {76        prefs.edit().putBoolean(KEY_PREFERRED, false).apply()77    }7879    fun configure(pin: String, vaultKey: ByteArray): Boolean {80        if (!isSupported() || pinValidationError(pin) != null || vaultKey.size != VaultCrypto.KEY_SIZE) return false81        clear()8283        var salt: ByteArray? = null84        var pinKey: ByteArray? = null85        var verifier: ByteArray? = null86        var wrapped: ByteArray? = null87        val createdAliases = mutableListOf<String>()88        return try {89            val vaultKeystoreKey = generateAesKey(VAULT_ALIAS, createdAliases)90            val verifierKeystoreKey = generateHmacKey(VERIFIER_ALIAS, limitedUse = false, createdAliases)91            salt = VaultCrypto.generateSalt()92            pinKey = VaultCrypto.deriveKey(pin, salt)93            verifier = hmac(verifierKeystoreKey, pinKey)9495            val cipher = Cipher.getInstance(TRANSFORM).apply { init(Cipher.ENCRYPT_MODE, vaultKeystoreKey) }96            wrapped = cipher.doFinal(vaultKey)97            createAttemptGates(createdAliases)9899            val saved = prefs.edit()100                .putInt(KEY_FORMAT, FORMAT_VERSION)101                .putString(KEY_SALT, b64(salt!!))102                .putString(KEY_VERIFIER, b64(verifier!!))103                .putString(KEY_WRAPPED_KEY, b64(wrapped!!))104                .putString(KEY_WRAPPED_IV, b64(cipher.iv))105                .putBoolean(KEY_PREFERRED, false)106                .commit()107            if (!saved) throw java.io.IOException("pin_storage_commit_failed")108            true109        } catch (_: Exception) {110            clear()111            false112        } finally {113            salt?.let(VaultCrypto::zero)114            pinKey?.let(VaultCrypto::zero)115            verifier?.let(VaultCrypto::zero)116            wrapped?.let(VaultCrypto::zero)117        }118    }119120    /**121     * Returns the vault key only after a hardware gate has been consumed and the122     * hardware HMAC verifier accepts the Argon2id-derived PIN key.123     */124    fun unlockVaultKey(pin: String): ByteArray? {125        if (pinValidationError(pin) != null || !isConfigured() || !consumeAttemptGate()) return null126        val salt = prefs.getString(KEY_SALT, null)?.let(::decode) ?: return null127        val expected = prefs.getString(KEY_VERIFIER, null)?.let(::decode) ?: return null128        var pinKey: ByteArray? = null129        var actual: ByteArray? = null130        return try {131            val verifierKey = existingSecretKey(VERIFIER_ALIAS) ?: return null132            pinKey = VaultCrypto.deriveKey(pin, salt)133            actual = hmac(verifierKey, pinKey)134            if (!MessageDigest.isEqual(expected, actual)) return null135136            val wrapped = prefs.getString(KEY_WRAPPED_KEY, null)?.let(::decode) ?: return null137            val iv = prefs.getString(KEY_WRAPPED_IV, null)?.let(::decode) ?: return null138            val vaultKey = Cipher.getInstance(TRANSFORM).run {139                init(Cipher.DECRYPT_MODE, existingSecretKey(VAULT_ALIAS) ?: return null, GCMParameterSpec(TAG_BITS, iv))140                doFinal(wrapped)141            }142            // A successful PIN proves possession of the PIN; replenish the143            // hardware attempt budget for the next lock/unlock cycle.144            replenishAttemptGates()145            vaultKey146        } catch (_: Exception) {147            null148        } finally {149            VaultCrypto.zero(salt)150            VaultCrypto.zero(expected)151            pinKey?.let(VaultCrypto::zero)152            actual?.let(VaultCrypto::zero)153        }154    }155156    fun areAttemptsExhausted(): Boolean = isConfigured() && loadAttemptAliases().isEmpty()157158    /** Regenerate the hardware attempt budget after a master-password unlock. */159    fun replenishAttemptGates() {160        if (!isConfigured()) return161        val oldAliases = loadAttemptAliases()162        val newAliases = mutableListOf<String>()163        try {164            createAttemptGates(newAliases)165            if (!prefs.edit().putString(KEY_ATTEMPT_ALIASES, newAliases.joinToString(DELIMITER)).commit()) {166                throw java.io.IOException("pin_attempt_gate_commit_failed")167            }168            oldAliases.forEach(::deleteKey)169        } catch (_: Exception) {170            newAliases.forEach(::deleteKey)171        }172    }173174    fun clear() {175        loadAttemptAliases().forEach(::deleteKey)176        listOf(VAULT_ALIAS, VERIFIER_ALIAS).forEach(::deleteKey)177        clearLegacyConfiguration()178        prefs.edit()179            .remove(KEY_FORMAT)180            .remove(KEY_SALT)181            .remove(KEY_VERIFIER)182            .remove(KEY_WRAPPED_KEY)183            .remove(KEY_WRAPPED_IV)184            .remove(KEY_ATTEMPT_ALIASES)185            .remove(KEY_PREFERRED)186            .commit()187    }188189    private fun consumeAttemptGate(): Boolean {190        val aliases = loadAttemptAliases()191        for (alias in aliases) {192            val key = existingSecretKey(alias) ?: continue193            try {194                // The output is intentionally discarded; the operation exists to195                // consume the one-use hardware authorization before KDF work.196                hmac(key, ATTEMPT_CHALLENGE)197                deleteKey(alias)198                prefs.edit().putString(199                    KEY_ATTEMPT_ALIASES,200                    aliases.filterNot { it == alias }.joinToString(DELIMITER),201                ).commit()202                return true203            } catch (_: KeyPermanentlyInvalidatedException) {204                deleteKey(alias)205            } catch (_: Exception) {206                deleteKey(alias)207            }208        }209        return false210    }211212    private fun createAttemptGates(createdAliases: MutableList<String>) {213        repeat(ATTEMPT_GATE_COUNT) {214            val alias = "$ATTEMPT_ALIAS_PREFIX${UUID.randomUUID()}"215            generateHmacKey(alias, limitedUse = true, createdAliases)216        }217        val aliases = createdAliases.filter { it.startsWith(ATTEMPT_ALIAS_PREFIX) }218        if (aliases.size != ATTEMPT_GATE_COUNT) throw java.io.IOException("pin_attempt_gate_count")219        prefs.edit().putString(KEY_ATTEMPT_ALIASES, aliases.joinToString(DELIMITER)).commit()220    }221222    @Suppress("NewApi")223    private fun generateAesKey(alias: String, createdAliases: MutableList<String>): SecretKey {224        existingSecretKey(alias)?.let { key ->225            if (isHardwareBacked(key)) return key226            deleteKey(alias)227        }228        val strongBoxPreferred = Build.VERSION.SDK_INT >= Build.VERSION_CODES.P &&229            appContext.packageManager.hasSystemFeature(PackageManager.FEATURE_STRONGBOX_KEYSTORE)230        val key = try {231            generateAesKey(alias, strongBoxPreferred)232        } catch (_: StrongBoxUnavailableException) {233            deleteKey(alias)234            generateAesKey(alias, false)235        }236        if (!isHardwareBacked(key)) {237            deleteKey(alias)238            throw java.security.GeneralSecurityException("pin_key_not_hardware_backed")239        }240        createdAliases += alias241        return key242    }243244    @Suppress("NewApi")245    private fun generateHmacKey(alias: String, limitedUse: Boolean, createdAliases: MutableList<String>): SecretKey {246        val strongBoxPreferred = Build.VERSION.SDK_INT >= Build.VERSION_CODES.P &&247            appContext.packageManager.hasSystemFeature(PackageManager.FEATURE_STRONGBOX_KEYSTORE)248        val key = try {249            generateHmacKey(alias, limitedUse, strongBoxPreferred)250        } catch (_: StrongBoxUnavailableException) {251            deleteKey(alias)252            generateHmacKey(alias, limitedUse, false)253        }254        if (!isHardwareBacked(key)) {255            deleteKey(alias)256            throw java.security.GeneralSecurityException("pin_key_not_hardware_backed")257        }258        createdAliases += alias259        return key260    }261262    private fun generateAesKey(alias: String, strongBox: Boolean): SecretKey {263        val builder = KeyGenParameterSpec.Builder(264            alias,265            KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT,266        )267            .setBlockModes(KeyProperties.BLOCK_MODE_GCM)268            .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)269            .setKeySize(256)270        if (strongBox && Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) builder.setIsStrongBoxBacked(true)271        val generator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, KEYSTORE)272        generator.init(builder.build())273        return generator.generateKey()274    }275276    private fun generateHmacKey(alias: String, limitedUse: Boolean, strongBox: Boolean): SecretKey {277        val builder = KeyGenParameterSpec.Builder(alias, KeyProperties.PURPOSE_SIGN)278            .setDigests(KeyProperties.DIGEST_SHA256)279            .setKeySize(256)280        if (limitedUse && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) builder.setMaxUsageCount(1)281        if (strongBox && Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) builder.setIsStrongBoxBacked(true)282        val generator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_HMAC_SHA256, KEYSTORE)283        generator.init(builder.build())284        return generator.generateKey()285    }286287    private fun hmac(key: SecretKey, input: ByteArray): ByteArray =288        Mac.getInstance("HmacSHA256").apply { init(key) }.doFinal(input)289290    private fun existingSecretKey(alias: String): SecretKey? = runCatching {291        val keyStore = KeyStore.getInstance(KEYSTORE).apply { load(null) }292        (keyStore.getEntry(alias, null) as? KeyStore.SecretKeyEntry)?.secretKey293    }.getOrNull()294295    private fun isHardwareBacked(key: SecretKey): Boolean = runCatching {296        val factory = SecretKeyFactory.getInstance(key.algorithm, KEYSTORE)297        val info = factory.getKeySpec(key, KeyInfo::class.java) as KeyInfo298        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {299            info.securityLevel == KeyProperties.SECURITY_LEVEL_TRUSTED_ENVIRONMENT ||300                info.securityLevel == KeyProperties.SECURITY_LEVEL_STRONGBOX301        } else {302            @Suppress("DEPRECATION")303            info.isInsideSecureHardware304        }305    }.getOrDefault(false)306307    private fun loadAttemptAliases(): List<String> =308        prefs.getString(KEY_ATTEMPT_ALIASES, null)309            ?.split(DELIMITER)310            ?.filter { it.startsWith(ATTEMPT_ALIAS_PREFIX) }311            ?: emptyList()312313    private fun deleteKey(alias: String) {314        runCatching { KeyStore.getInstance(KEYSTORE).apply { load(null) }.deleteEntry(alias) }315    }316317    private fun clearLegacyConfiguration() {318        prefs.edit()319            .remove("peach_pin_salt")320            .remove("peach_pin_verifier")321            .remove("peach_pin_wrapped_key")322            .remove("peach_pin_preferred")323            .commit()324    }325326    private fun b64(bytes: ByteArray): String = Base64.getEncoder().withoutPadding().encodeToString(bytes)327    private fun decode(value: String): ByteArray = Base64.getDecoder().decode(value)328329    private companion object {330        const val FORMAT_VERSION = 2331        const val PREFS = "peach_vault_store"332        const val KEY_FORMAT = "peach_pin_format"333        const val KEY_SALT = "peach_pin_salt_v2"334        const val KEY_VERIFIER = "peach_pin_verifier_v2"335        const val KEY_WRAPPED_KEY = "peach_pin_wrapped_key_v2"336        const val KEY_WRAPPED_IV = "peach_pin_wrapped_iv_v2"337        const val KEY_ATTEMPT_ALIASES = "peach_pin_attempt_aliases_v2"338        const val KEY_PREFERRED = "peach_pin_preferred_v2"339        const val KEYSTORE = "AndroidKeyStore"340        const val VAULT_ALIAS = "peach_pin_vault_key_v2"341        const val VERIFIER_ALIAS = "peach_pin_verifier_key_v2"342        const val ATTEMPT_ALIAS_PREFIX = "peach_pin_attempt_v2_"343        const val ATTEMPT_GATE_COUNT = 5344        const val TRANSFORM = "AES/GCM/NoPadding"345        const val TAG_BITS = 128346        const val DELIMITER = ","347        val ATTEMPT_CHALLENGE = "peach_pin_attempt_gate_v2".toByteArray(Charsets.UTF_8)348    }349}