PinUnlockStore.kt

16.7 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        when (prefs.getInt(KEY_FORMAT, 0)) {56            FORMAT_VERSION -> Unit57            0 -> clearKnownKeystoreAliases()58            else -> clear()59        }60    }6162    /** New PIN setup is safe only where Android reports hardware limited-use keys. */63    fun isSupported(): Boolean = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S &&64        (appContext.packageManager.hasSystemFeature(PackageManager.FEATURE_KEYSTORE_LIMITED_USE_KEY) ||65            appContext.packageManager.hasSystemFeature(PackageManager.FEATURE_KEYSTORE_SINGLE_USE_KEY))6667    fun isConfigured(): Boolean =68        prefs.getInt(KEY_FORMAT, 0) == FORMAT_VERSION &&69            prefs.contains(KEY_SALT) &&70            prefs.contains(KEY_VERIFIER) &&71            prefs.contains(KEY_WRAPPED_KEY) &&72            prefs.contains(KEY_WRAPPED_IV) &&73            existingSecretKey(VAULT_ALIAS) != null &&74            existingSecretKey(VERIFIER_ALIAS) != null7576    fun isPinPreferred(): Boolean = prefs.getBoolean(KEY_PREFERRED, false)7778    fun markPinUsed() {79        prefs.edit().putBoolean(KEY_PREFERRED, true).apply()80    }8182    fun markPasswordUsed() {83        prefs.edit().putBoolean(KEY_PREFERRED, false).apply()84    }8586    fun configure(pin: String, vaultKey: ByteArray): Boolean {87        if (!isSupported() || pinValidationError(pin) != null || vaultKey.size != VaultCrypto.KEY_SIZE) return false88        if (!probeLimitedUseEnforcement()) return false89        clear()9091        var salt: ByteArray? = null92        var pinKey: ByteArray? = null93        var verifier: ByteArray? = null94        var wrapped: ByteArray? = null95        val createdAliases = mutableListOf<String>()96        return try {97            val vaultKeystoreKey = generateAesKey(VAULT_ALIAS, createdAliases)98            val verifierKeystoreKey = generateHmacKey(VERIFIER_ALIAS, limitedUse = false, createdAliases)99            salt = VaultCrypto.generateSalt()100            pinKey = VaultCrypto.deriveKey(pin, salt)101            verifier = hmac(verifierKeystoreKey, pinKey)102103            val cipher = Cipher.getInstance(TRANSFORM).apply { init(Cipher.ENCRYPT_MODE, vaultKeystoreKey) }104            wrapped = cipher.doFinal(vaultKey)105            val attemptAliases = createAttemptGates(createdAliases)106107            val saved = prefs.edit()108                .putInt(KEY_FORMAT, FORMAT_VERSION)109                .putString(KEY_SALT, b64(salt!!))110                .putString(KEY_VERIFIER, b64(verifier!!))111                .putString(KEY_WRAPPED_KEY, b64(wrapped!!))112                .putString(KEY_WRAPPED_IV, b64(cipher.iv))113                .putString(KEY_ATTEMPT_ALIASES, attemptAliases.joinToString(DELIMITER))114                .putBoolean(KEY_PREFERRED, false)115                .commit()116            if (!saved) throw java.io.IOException("pin_storage_commit_failed")117            true118        } catch (_: Exception) {119            createdAliases.forEach(::deleteKey)120            clear()121            false122        } finally {123            salt?.let(VaultCrypto::zero)124            pinKey?.let(VaultCrypto::zero)125            verifier?.let(VaultCrypto::zero)126            wrapped?.let(VaultCrypto::zero)127        }128    }129130    /**131     * Returns the vault key only after a hardware gate has been consumed and the132     * hardware HMAC verifier accepts the Argon2id-derived PIN key.133     */134    fun unlockVaultKey(pin: String): ByteArray? {135        if (pinValidationError(pin) != null || !isConfigured() || !consumeAttemptGate()) return null136        var salt: ByteArray? = null137        var expected: ByteArray? = null138        var pinKey: ByteArray? = null139        var actual: ByteArray? = null140        var wrapped: ByteArray? = null141        var iv: ByteArray? = null142        return try {143            salt = prefs.getString(KEY_SALT, null)?.let(::decode) ?: return null144            expected = prefs.getString(KEY_VERIFIER, null)?.let(::decode) ?: return null145            val verifierKey = existingSecretKey(VERIFIER_ALIAS) ?: return null146            pinKey = VaultCrypto.deriveKey(pin, salt!!)147            actual = hmac(verifierKey, pinKey)148            if (!MessageDigest.isEqual(expected, actual)) return null149150            wrapped = prefs.getString(KEY_WRAPPED_KEY, null)?.let(::decode) ?: return null151            iv = prefs.getString(KEY_WRAPPED_IV, null)?.let(::decode) ?: return null152            val vaultKey = Cipher.getInstance(TRANSFORM).run {153                init(Cipher.DECRYPT_MODE, existingSecretKey(VAULT_ALIAS) ?: return null, GCMParameterSpec(TAG_BITS, iv!!))154                doFinal(wrapped!!)155            }156            // A successful PIN proves possession of the PIN; replenish the157            // hardware attempt budget for the next lock/unlock cycle.158            replenishAttemptGates()159            vaultKey160        } catch (_: Exception) {161            null162        } finally {163            salt?.let(VaultCrypto::zero)164            expected?.let(VaultCrypto::zero)165            pinKey?.let(VaultCrypto::zero)166            actual?.let(VaultCrypto::zero)167            wrapped?.let(VaultCrypto::zero)168            iv?.let(VaultCrypto::zero)169        }170    }171172    fun areAttemptsExhausted(): Boolean = isConfigured() &&173        loadAttemptAliases().none { existingSecretKey(it) != null }174175    /**176     * Prove this device actually enforces a one-use hardware HMAC key instead177     * of trusting its feature flag. PIN setup proceeds only when the first MAC178     * succeeds and the same key is then unavailable or rejects a second MAC.179     */180    internal fun probeLimitedUseEnforcement(): Boolean {181        if (!isSupported()) return false182        val alias = "$PROBE_ALIAS_PREFIX${UUID.randomUUID()}"183        val createdAliases = mutableListOf<String>()184        return try {185            val key = generateHmacKey(alias, limitedUse = true, createdAliases)186            hmac(key, ATTEMPT_CHALLENGE).let(VaultCrypto::zero)187            val remainingKey = existingSecretKey(alias) ?: return true188            val secondUseSucceeded = runCatching {189                hmac(remainingKey, ATTEMPT_CHALLENGE).let(VaultCrypto::zero)190            }.isSuccess191            !secondUseSucceeded192        } catch (_: Exception) {193            false194        } finally {195            deleteKey(alias)196        }197    }198199    /** Regenerate the hardware attempt budget after a master-password unlock. */200    fun replenishAttemptGates() {201        if (!isConfigured()) return202        val oldAliases = loadAttemptAliases()203        val newAliases = mutableListOf<String>()204        try {205            val attemptAliases = createAttemptGates(newAliases)206            if (!prefs.edit().putString(KEY_ATTEMPT_ALIASES, attemptAliases.joinToString(DELIMITER)).commit()) {207                throw java.io.IOException("pin_attempt_gate_commit_failed")208            }209            oldAliases.forEach(::deleteKey)210        } catch (_: Exception) {211            newAliases.forEach(::deleteKey)212        }213    }214215    fun clear() {216        loadAttemptAliases().forEach(::deleteKey)217        listOf(VAULT_ALIAS, VERIFIER_ALIAS).forEach(::deleteKey)218        clearLegacyConfiguration()219        prefs.edit()220            .remove(KEY_FORMAT)221            .remove(KEY_SALT)222            .remove(KEY_VERIFIER)223            .remove(KEY_WRAPPED_KEY)224            .remove(KEY_WRAPPED_IV)225            .remove(KEY_ATTEMPT_ALIASES)226            .remove(KEY_PREFERRED)227            .commit()228    }229230    private fun consumeAttemptGate(): Boolean {231        val aliases = loadAttemptAliases()232        for (alias in aliases) {233            val key = existingSecretKey(alias) ?: continue234            try {235                // The output is intentionally discarded; the operation exists to236                // consume the one-use hardware authorization before KDF work.237                hmac(key, ATTEMPT_CHALLENGE)238                deleteKey(alias)239                prefs.edit().putString(240                    KEY_ATTEMPT_ALIASES,241                    aliases.filterNot { it == alias }.joinToString(DELIMITER),242                ).commit()243                return true244            } catch (_: KeyPermanentlyInvalidatedException) {245                deleteKey(alias)246            } catch (_: Exception) {247                deleteKey(alias)248            }249        }250        return false251    }252253    private fun createAttemptGates(createdAliases: MutableList<String>): List<String> {254        repeat(ATTEMPT_GATE_COUNT) {255            val alias = "$ATTEMPT_ALIAS_PREFIX${UUID.randomUUID()}"256            generateHmacKey(alias, limitedUse = true, createdAliases)257        }258        val aliases = createdAliases.filter { it.startsWith(ATTEMPT_ALIAS_PREFIX) }259        if (aliases.size != ATTEMPT_GATE_COUNT) throw java.io.IOException("pin_attempt_gate_count")260        return aliases261    }262263    @Suppress("NewApi")264    private fun generateAesKey(alias: String, createdAliases: MutableList<String>): SecretKey {265        existingSecretKey(alias)?.let { key ->266            if (isHardwareBacked(key)) return key267            deleteKey(alias)268        }269        val strongBoxPreferred = Build.VERSION.SDK_INT >= Build.VERSION_CODES.P &&270            appContext.packageManager.hasSystemFeature(PackageManager.FEATURE_STRONGBOX_KEYSTORE)271        val key = try {272            generateAesKey(alias, strongBoxPreferred)273        } catch (_: StrongBoxUnavailableException) {274            deleteKey(alias)275            generateAesKey(alias, false)276        }277        if (!isHardwareBacked(key)) {278            deleteKey(alias)279            throw java.security.GeneralSecurityException("pin_key_not_hardware_backed")280        }281        createdAliases += alias282        return key283    }284285    @Suppress("NewApi")286    private fun generateHmacKey(alias: String, limitedUse: Boolean, createdAliases: MutableList<String>): SecretKey {287        val strongBoxPreferred = Build.VERSION.SDK_INT >= Build.VERSION_CODES.P &&288            appContext.packageManager.hasSystemFeature(PackageManager.FEATURE_STRONGBOX_KEYSTORE)289        val key = try {290            generateHmacKey(alias, limitedUse, strongBoxPreferred)291        } catch (_: StrongBoxUnavailableException) {292            deleteKey(alias)293            generateHmacKey(alias, limitedUse, false)294        }295        if (!isHardwareBacked(key)) {296            deleteKey(alias)297            throw java.security.GeneralSecurityException("pin_key_not_hardware_backed")298        }299        createdAliases += alias300        return key301    }302303    private fun generateAesKey(alias: String, strongBox: Boolean): SecretKey {304        val builder = KeyGenParameterSpec.Builder(305            alias,306            KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT,307        )308            .setBlockModes(KeyProperties.BLOCK_MODE_GCM)309            .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)310            .setKeySize(256)311        if (strongBox && Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) builder.setIsStrongBoxBacked(true)312        val generator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, KEYSTORE)313        generator.init(builder.build())314        return generator.generateKey()315    }316317    private fun generateHmacKey(alias: String, limitedUse: Boolean, strongBox: Boolean): SecretKey {318        val builder = KeyGenParameterSpec.Builder(alias, KeyProperties.PURPOSE_SIGN)319            .setDigests(KeyProperties.DIGEST_SHA256)320            .setKeySize(256)321        if (limitedUse && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) builder.setMaxUsageCount(1)322        if (strongBox && Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) builder.setIsStrongBoxBacked(true)323        val generator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_HMAC_SHA256, KEYSTORE)324        generator.init(builder.build())325        return generator.generateKey()326    }327328    private fun hmac(key: SecretKey, input: ByteArray): ByteArray =329        Mac.getInstance("HmacSHA256").apply { init(key) }.doFinal(input)330331    private fun existingSecretKey(alias: String): SecretKey? = runCatching {332        val keyStore = KeyStore.getInstance(KEYSTORE).apply { load(null) }333        (keyStore.getEntry(alias, null) as? KeyStore.SecretKeyEntry)?.secretKey334    }.getOrNull()335336    private fun isHardwareBacked(key: SecretKey): Boolean = runCatching {337        val factory = SecretKeyFactory.getInstance(key.algorithm, KEYSTORE)338        val info = factory.getKeySpec(key, KeyInfo::class.java) as KeyInfo339        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {340            info.securityLevel == KeyProperties.SECURITY_LEVEL_TRUSTED_ENVIRONMENT ||341                info.securityLevel == KeyProperties.SECURITY_LEVEL_STRONGBOX342        } else {343            @Suppress("DEPRECATION")344            info.isInsideSecureHardware345        }346    }.getOrDefault(false)347348    private fun loadAttemptAliases(): List<String> =349        prefs.getString(KEY_ATTEMPT_ALIASES, null)350            ?.split(DELIMITER)351            ?.filter { it.startsWith(ATTEMPT_ALIAS_PREFIX) }352            ?: emptyList()353354    private fun deleteKey(alias: String) {355        runCatching { KeyStore.getInstance(KEYSTORE).apply { load(null) }.deleteEntry(alias) }356    }357358    private fun clearKnownKeystoreAliases() {359        runCatching {360            KeyStore.getInstance(KEYSTORE).apply { load(null) }.aliases().toList()361                .filter {362                    it == VAULT_ALIAS || it == VERIFIER_ALIAS ||363                        it.startsWith(ATTEMPT_ALIAS_PREFIX) || it.startsWith(PROBE_ALIAS_PREFIX)364                }365                .forEach(::deleteKey)366        }367    }368369    private fun clearLegacyConfiguration() {370        prefs.edit()371            .remove("peach_pin_salt")372            .remove("peach_pin_verifier")373            .remove("peach_pin_wrapped_key")374            .remove("peach_pin_preferred")375            .commit()376    }377378    private fun b64(bytes: ByteArray): String = Base64.getEncoder().withoutPadding().encodeToString(bytes)379    private fun decode(value: String): ByteArray = Base64.getDecoder().decode(value)380381    private companion object {382        const val FORMAT_VERSION = 3383        const val PREFS = "peach_vault_store"384        const val KEY_FORMAT = "peach_pin_format"385        const val KEY_SALT = "peach_pin_salt_v2"386        const val KEY_VERIFIER = "peach_pin_verifier_v2"387        const val KEY_WRAPPED_KEY = "peach_pin_wrapped_key_v2"388        const val KEY_WRAPPED_IV = "peach_pin_wrapped_iv_v2"389        const val KEY_ATTEMPT_ALIASES = "peach_pin_attempt_aliases_v2"390        const val KEY_PREFERRED = "peach_pin_preferred_v2"391        const val KEYSTORE = "AndroidKeyStore"392        const val VAULT_ALIAS = "peach_pin_vault_key_v2"393        const val VERIFIER_ALIAS = "peach_pin_verifier_key_v2"394        const val ATTEMPT_ALIAS_PREFIX = "peach_pin_attempt_v2_"395        const val PROBE_ALIAS_PREFIX = "peach_pin_probe_v3_"396        const val ATTEMPT_GATE_COUNT = 5397        const val TRANSFORM = "AES/GCM/NoPadding"398        const val TAG_BITS = 128399        const val DELIMITER = ","400        val ATTEMPT_CHALLENGE = "peach_pin_attempt_gate_v2".toByteArray(Charsets.UTF_8)401    }402}