PinUnlockStore.kt

15.5 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        if (prefs.getInt(KEY_FORMAT, 0) != FORMAT_VERSION) clearKnownKeystoreAliases()56    }5758    /** New PIN setup is safe only where Android reports hardware limited-use keys. */59    fun isSupported(): Boolean = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S &&60        (appContext.packageManager.hasSystemFeature(PackageManager.FEATURE_KEYSTORE_LIMITED_USE_KEY) ||61            appContext.packageManager.hasSystemFeature(PackageManager.FEATURE_KEYSTORE_SINGLE_USE_KEY))6263    fun isConfigured(): Boolean =64        prefs.getInt(KEY_FORMAT, 0) == FORMAT_VERSION &&65            prefs.contains(KEY_SALT) &&66            prefs.contains(KEY_VERIFIER) &&67            prefs.contains(KEY_WRAPPED_KEY) &&68            prefs.contains(KEY_WRAPPED_IV) &&69            existingSecretKey(VAULT_ALIAS) != null &&70            existingSecretKey(VERIFIER_ALIAS) != null7172    fun isPinPreferred(): Boolean = prefs.getBoolean(KEY_PREFERRED, false)7374    fun markPinUsed() {75        prefs.edit().putBoolean(KEY_PREFERRED, true).apply()76    }7778    fun markPasswordUsed() {79        prefs.edit().putBoolean(KEY_PREFERRED, false).apply()80    }8182    fun configure(pin: String, vaultKey: ByteArray): Boolean {83        if (!isSupported() || pinValidationError(pin) != null || vaultKey.size != VaultCrypto.KEY_SIZE) return false84        clear()8586        var salt: ByteArray? = null87        var pinKey: ByteArray? = null88        var verifier: ByteArray? = null89        var wrapped: ByteArray? = null90        val createdAliases = mutableListOf<String>()91        return try {92            val vaultKeystoreKey = generateAesKey(VAULT_ALIAS, createdAliases)93            val verifierKeystoreKey = generateHmacKey(VERIFIER_ALIAS, limitedUse = false, createdAliases)94            salt = VaultCrypto.generateSalt()95            pinKey = VaultCrypto.deriveKey(pin, salt)96            verifier = hmac(verifierKeystoreKey, pinKey)9798            val cipher = Cipher.getInstance(TRANSFORM).apply { init(Cipher.ENCRYPT_MODE, vaultKeystoreKey) }99            wrapped = cipher.doFinal(vaultKey)100            val attemptAliases = createAttemptGates(createdAliases)101102            val saved = prefs.edit()103                .putInt(KEY_FORMAT, FORMAT_VERSION)104                .putString(KEY_SALT, b64(salt!!))105                .putString(KEY_VERIFIER, b64(verifier!!))106                .putString(KEY_WRAPPED_KEY, b64(wrapped!!))107                .putString(KEY_WRAPPED_IV, b64(cipher.iv))108                .putString(KEY_ATTEMPT_ALIASES, attemptAliases.joinToString(DELIMITER))109                .putBoolean(KEY_PREFERRED, false)110                .commit()111            if (!saved) throw java.io.IOException("pin_storage_commit_failed")112            true113        } catch (_: Exception) {114            createdAliases.forEach(::deleteKey)115            clear()116            false117        } finally {118            salt?.let(VaultCrypto::zero)119            pinKey?.let(VaultCrypto::zero)120            verifier?.let(VaultCrypto::zero)121            wrapped?.let(VaultCrypto::zero)122        }123    }124125    /**126     * Returns the vault key only after a hardware gate has been consumed and the127     * hardware HMAC verifier accepts the Argon2id-derived PIN key.128     */129    fun unlockVaultKey(pin: String): ByteArray? {130        if (pinValidationError(pin) != null || !isConfigured() || !consumeAttemptGate()) return null131        var salt: ByteArray? = null132        var expected: ByteArray? = null133        var pinKey: ByteArray? = null134        var actual: ByteArray? = null135        var wrapped: ByteArray? = null136        var iv: ByteArray? = null137        return try {138            salt = prefs.getString(KEY_SALT, null)?.let(::decode) ?: return null139            expected = prefs.getString(KEY_VERIFIER, null)?.let(::decode) ?: return null140            val verifierKey = existingSecretKey(VERIFIER_ALIAS) ?: return null141            pinKey = VaultCrypto.deriveKey(pin, salt!!)142            actual = hmac(verifierKey, pinKey)143            if (!MessageDigest.isEqual(expected, actual)) return null144145            wrapped = prefs.getString(KEY_WRAPPED_KEY, null)?.let(::decode) ?: return null146            iv = prefs.getString(KEY_WRAPPED_IV, null)?.let(::decode) ?: return null147            val vaultKey = Cipher.getInstance(TRANSFORM).run {148                init(Cipher.DECRYPT_MODE, existingSecretKey(VAULT_ALIAS) ?: return null, GCMParameterSpec(TAG_BITS, iv!!))149                doFinal(wrapped!!)150            }151            // A successful PIN proves possession of the PIN; replenish the152            // hardware attempt budget for the next lock/unlock cycle.153            replenishAttemptGates()154            vaultKey155        } catch (_: Exception) {156            null157        } finally {158            salt?.let(VaultCrypto::zero)159            expected?.let(VaultCrypto::zero)160            pinKey?.let(VaultCrypto::zero)161            actual?.let(VaultCrypto::zero)162            wrapped?.let(VaultCrypto::zero)163            iv?.let(VaultCrypto::zero)164        }165    }166167    fun areAttemptsExhausted(): Boolean = isConfigured() &&168        loadAttemptAliases().none { existingSecretKey(it) != null }169170    /** Regenerate the hardware attempt budget after a master-password unlock. */171    fun replenishAttemptGates() {172        if (!isConfigured()) return173        val oldAliases = loadAttemptAliases()174        val newAliases = mutableListOf<String>()175        try {176            val attemptAliases = createAttemptGates(newAliases)177            if (!prefs.edit().putString(KEY_ATTEMPT_ALIASES, attemptAliases.joinToString(DELIMITER)).commit()) {178                throw java.io.IOException("pin_attempt_gate_commit_failed")179            }180            oldAliases.forEach(::deleteKey)181        } catch (_: Exception) {182            newAliases.forEach(::deleteKey)183        }184    }185186    fun clear() {187        loadAttemptAliases().forEach(::deleteKey)188        listOf(VAULT_ALIAS, VERIFIER_ALIAS).forEach(::deleteKey)189        clearLegacyConfiguration()190        prefs.edit()191            .remove(KEY_FORMAT)192            .remove(KEY_SALT)193            .remove(KEY_VERIFIER)194            .remove(KEY_WRAPPED_KEY)195            .remove(KEY_WRAPPED_IV)196            .remove(KEY_ATTEMPT_ALIASES)197            .remove(KEY_PREFERRED)198            .commit()199    }200201    private fun consumeAttemptGate(): Boolean {202        val aliases = loadAttemptAliases()203        for (alias in aliases) {204            val key = existingSecretKey(alias) ?: continue205            try {206                // The output is intentionally discarded; the operation exists to207                // consume the one-use hardware authorization before KDF work.208                hmac(key, ATTEMPT_CHALLENGE)209                deleteKey(alias)210                prefs.edit().putString(211                    KEY_ATTEMPT_ALIASES,212                    aliases.filterNot { it == alias }.joinToString(DELIMITER),213                ).commit()214                return true215            } catch (_: KeyPermanentlyInvalidatedException) {216                deleteKey(alias)217            } catch (_: Exception) {218                deleteKey(alias)219            }220        }221        return false222    }223224    private fun createAttemptGates(createdAliases: MutableList<String>): List<String> {225        repeat(ATTEMPT_GATE_COUNT) {226            val alias = "$ATTEMPT_ALIAS_PREFIX${UUID.randomUUID()}"227            generateHmacKey(alias, limitedUse = true, createdAliases)228        }229        val aliases = createdAliases.filter { it.startsWith(ATTEMPT_ALIAS_PREFIX) }230        if (aliases.size != ATTEMPT_GATE_COUNT) throw java.io.IOException("pin_attempt_gate_count")231        return aliases232    }233234    @Suppress("NewApi")235    private fun generateAesKey(alias: String, createdAliases: MutableList<String>): SecretKey {236        existingSecretKey(alias)?.let { key ->237            if (isHardwareBacked(key)) return key238            deleteKey(alias)239        }240        val strongBoxPreferred = Build.VERSION.SDK_INT >= Build.VERSION_CODES.P &&241            appContext.packageManager.hasSystemFeature(PackageManager.FEATURE_STRONGBOX_KEYSTORE)242        val key = try {243            generateAesKey(alias, strongBoxPreferred)244        } catch (_: StrongBoxUnavailableException) {245            deleteKey(alias)246            generateAesKey(alias, false)247        }248        if (!isHardwareBacked(key)) {249            deleteKey(alias)250            throw java.security.GeneralSecurityException("pin_key_not_hardware_backed")251        }252        createdAliases += alias253        return key254    }255256    @Suppress("NewApi")257    private fun generateHmacKey(alias: String, limitedUse: Boolean, createdAliases: MutableList<String>): SecretKey {258        val strongBoxPreferred = Build.VERSION.SDK_INT >= Build.VERSION_CODES.P &&259            appContext.packageManager.hasSystemFeature(PackageManager.FEATURE_STRONGBOX_KEYSTORE)260        val key = try {261            generateHmacKey(alias, limitedUse, strongBoxPreferred)262        } catch (_: StrongBoxUnavailableException) {263            deleteKey(alias)264            generateHmacKey(alias, limitedUse, false)265        }266        if (!isHardwareBacked(key)) {267            deleteKey(alias)268            throw java.security.GeneralSecurityException("pin_key_not_hardware_backed")269        }270        createdAliases += alias271        return key272    }273274    private fun generateAesKey(alias: String, strongBox: Boolean): SecretKey {275        val builder = KeyGenParameterSpec.Builder(276            alias,277            KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT,278        )279            .setBlockModes(KeyProperties.BLOCK_MODE_GCM)280            .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)281            .setKeySize(256)282        if (strongBox && Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) builder.setIsStrongBoxBacked(true)283        val generator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, KEYSTORE)284        generator.init(builder.build())285        return generator.generateKey()286    }287288    private fun generateHmacKey(alias: String, limitedUse: Boolean, strongBox: Boolean): SecretKey {289        val builder = KeyGenParameterSpec.Builder(alias, KeyProperties.PURPOSE_SIGN)290            .setDigests(KeyProperties.DIGEST_SHA256)291            .setKeySize(256)292        if (limitedUse && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) builder.setMaxUsageCount(1)293        if (strongBox && Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) builder.setIsStrongBoxBacked(true)294        val generator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_HMAC_SHA256, KEYSTORE)295        generator.init(builder.build())296        return generator.generateKey()297    }298299    private fun hmac(key: SecretKey, input: ByteArray): ByteArray =300        Mac.getInstance("HmacSHA256").apply { init(key) }.doFinal(input)301302    private fun existingSecretKey(alias: String): SecretKey? = runCatching {303        val keyStore = KeyStore.getInstance(KEYSTORE).apply { load(null) }304        (keyStore.getEntry(alias, null) as? KeyStore.SecretKeyEntry)?.secretKey305    }.getOrNull()306307    private fun isHardwareBacked(key: SecretKey): Boolean = runCatching {308        val factory = SecretKeyFactory.getInstance(key.algorithm, KEYSTORE)309        val info = factory.getKeySpec(key, KeyInfo::class.java) as KeyInfo310        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {311            info.securityLevel == KeyProperties.SECURITY_LEVEL_TRUSTED_ENVIRONMENT ||312                info.securityLevel == KeyProperties.SECURITY_LEVEL_STRONGBOX313        } else {314            @Suppress("DEPRECATION")315            info.isInsideSecureHardware316        }317    }.getOrDefault(false)318319    private fun loadAttemptAliases(): List<String> =320        prefs.getString(KEY_ATTEMPT_ALIASES, null)321            ?.split(DELIMITER)322            ?.filter { it.startsWith(ATTEMPT_ALIAS_PREFIX) }323            ?: emptyList()324325    private fun deleteKey(alias: String) {326        runCatching { KeyStore.getInstance(KEYSTORE).apply { load(null) }.deleteEntry(alias) }327    }328329    private fun clearKnownKeystoreAliases() {330        runCatching {331            KeyStore.getInstance(KEYSTORE).apply { load(null) }.aliases().toList()332                .filter { it == VAULT_ALIAS || it == VERIFIER_ALIAS || it.startsWith(ATTEMPT_ALIAS_PREFIX) }333                .forEach(::deleteKey)334        }335    }336337    private fun clearLegacyConfiguration() {338        prefs.edit()339            .remove("peach_pin_salt")340            .remove("peach_pin_verifier")341            .remove("peach_pin_wrapped_key")342            .remove("peach_pin_preferred")343            .commit()344    }345346    private fun b64(bytes: ByteArray): String = Base64.getEncoder().withoutPadding().encodeToString(bytes)347    private fun decode(value: String): ByteArray = Base64.getDecoder().decode(value)348349    private companion object {350        const val FORMAT_VERSION = 2351        const val PREFS = "peach_vault_store"352        const val KEY_FORMAT = "peach_pin_format"353        const val KEY_SALT = "peach_pin_salt_v2"354        const val KEY_VERIFIER = "peach_pin_verifier_v2"355        const val KEY_WRAPPED_KEY = "peach_pin_wrapped_key_v2"356        const val KEY_WRAPPED_IV = "peach_pin_wrapped_iv_v2"357        const val KEY_ATTEMPT_ALIASES = "peach_pin_attempt_aliases_v2"358        const val KEY_PREFERRED = "peach_pin_preferred_v2"359        const val KEYSTORE = "AndroidKeyStore"360        const val VAULT_ALIAS = "peach_pin_vault_key_v2"361        const val VERIFIER_ALIAS = "peach_pin_verifier_key_v2"362        const val ATTEMPT_ALIAS_PREFIX = "peach_pin_attempt_v2_"363        const val ATTEMPT_GATE_COUNT = 5364        const val TRANSFORM = "AES/GCM/NoPadding"365        const val TAG_BITS = 128366        const val DELIMITER = ","367        val ATTEMPT_CHALLENGE = "peach_pin_attempt_gate_v2".toByteArray(Charsets.UTF_8)368    }369}