PinUnlockStore.kt

14.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    }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            val attemptAliases = 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                .putString(KEY_ATTEMPT_ALIASES, attemptAliases.joinToString(DELIMITER))106                .putBoolean(KEY_PREFERRED, false)107                .commit()108            if (!saved) throw java.io.IOException("pin_storage_commit_failed")109            true110        } catch (_: Exception) {111            createdAliases.forEach(::deleteKey)112            clear()113            false114        } finally {115            salt?.let(VaultCrypto::zero)116            pinKey?.let(VaultCrypto::zero)117            verifier?.let(VaultCrypto::zero)118            wrapped?.let(VaultCrypto::zero)119        }120    }121122    /**123     * Returns the vault key only after a hardware gate has been consumed and the124     * hardware HMAC verifier accepts the Argon2id-derived PIN key.125     */126    fun unlockVaultKey(pin: String): ByteArray? {127        if (pinValidationError(pin) != null || !isConfigured() || !consumeAttemptGate()) return null128        val salt = prefs.getString(KEY_SALT, null)?.let(::decode) ?: return null129        val expected = prefs.getString(KEY_VERIFIER, null)?.let(::decode) ?: return null130        var pinKey: ByteArray? = null131        var actual: ByteArray? = null132        return try {133            val verifierKey = existingSecretKey(VERIFIER_ALIAS) ?: return null134            pinKey = VaultCrypto.deriveKey(pin, salt)135            actual = hmac(verifierKey, pinKey)136            if (!MessageDigest.isEqual(expected, actual)) return null137138            val wrapped = prefs.getString(KEY_WRAPPED_KEY, null)?.let(::decode) ?: return null139            val iv = prefs.getString(KEY_WRAPPED_IV, null)?.let(::decode) ?: return null140            val vaultKey = Cipher.getInstance(TRANSFORM).run {141                init(Cipher.DECRYPT_MODE, existingSecretKey(VAULT_ALIAS) ?: return null, GCMParameterSpec(TAG_BITS, iv))142                doFinal(wrapped)143            }144            // A successful PIN proves possession of the PIN; replenish the145            // hardware attempt budget for the next lock/unlock cycle.146            replenishAttemptGates()147            vaultKey148        } catch (_: Exception) {149            null150        } finally {151            VaultCrypto.zero(salt)152            VaultCrypto.zero(expected)153            pinKey?.let(VaultCrypto::zero)154            actual?.let(VaultCrypto::zero)155        }156    }157158    fun areAttemptsExhausted(): Boolean = isConfigured() && loadAttemptAliases().isEmpty()159160    /** Regenerate the hardware attempt budget after a master-password unlock. */161    fun replenishAttemptGates() {162        if (!isConfigured()) return163        val oldAliases = loadAttemptAliases()164        val newAliases = mutableListOf<String>()165        try {166            val attemptAliases = createAttemptGates(newAliases)167            if (!prefs.edit().putString(KEY_ATTEMPT_ALIASES, attemptAliases.joinToString(DELIMITER)).commit()) {168                throw java.io.IOException("pin_attempt_gate_commit_failed")169            }170            oldAliases.forEach(::deleteKey)171        } catch (_: Exception) {172            newAliases.forEach(::deleteKey)173        }174    }175176    fun clear() {177        loadAttemptAliases().forEach(::deleteKey)178        listOf(VAULT_ALIAS, VERIFIER_ALIAS).forEach(::deleteKey)179        clearLegacyConfiguration()180        prefs.edit()181            .remove(KEY_FORMAT)182            .remove(KEY_SALT)183            .remove(KEY_VERIFIER)184            .remove(KEY_WRAPPED_KEY)185            .remove(KEY_WRAPPED_IV)186            .remove(KEY_ATTEMPT_ALIASES)187            .remove(KEY_PREFERRED)188            .commit()189    }190191    private fun consumeAttemptGate(): Boolean {192        val aliases = loadAttemptAliases()193        for (alias in aliases) {194            val key = existingSecretKey(alias) ?: continue195            try {196                // The output is intentionally discarded; the operation exists to197                // consume the one-use hardware authorization before KDF work.198                hmac(key, ATTEMPT_CHALLENGE)199                deleteKey(alias)200                prefs.edit().putString(201                    KEY_ATTEMPT_ALIASES,202                    aliases.filterNot { it == alias }.joinToString(DELIMITER),203                ).commit()204                return true205            } catch (_: KeyPermanentlyInvalidatedException) {206                deleteKey(alias)207            } catch (_: Exception) {208                deleteKey(alias)209            }210        }211        return false212    }213214    private fun createAttemptGates(createdAliases: MutableList<String>): List<String> {215        repeat(ATTEMPT_GATE_COUNT) {216            val alias = "$ATTEMPT_ALIAS_PREFIX${UUID.randomUUID()}"217            generateHmacKey(alias, limitedUse = true, createdAliases)218        }219        val aliases = createdAliases.filter { it.startsWith(ATTEMPT_ALIAS_PREFIX) }220        if (aliases.size != ATTEMPT_GATE_COUNT) throw java.io.IOException("pin_attempt_gate_count")221        return aliases222    }223224    @Suppress("NewApi")225    private fun generateAesKey(alias: String, createdAliases: MutableList<String>): SecretKey {226        existingSecretKey(alias)?.let { key ->227            if (isHardwareBacked(key)) return key228            deleteKey(alias)229        }230        val strongBoxPreferred = Build.VERSION.SDK_INT >= Build.VERSION_CODES.P &&231            appContext.packageManager.hasSystemFeature(PackageManager.FEATURE_STRONGBOX_KEYSTORE)232        val key = try {233            generateAesKey(alias, strongBoxPreferred)234        } catch (_: StrongBoxUnavailableException) {235            deleteKey(alias)236            generateAesKey(alias, false)237        }238        if (!isHardwareBacked(key)) {239            deleteKey(alias)240            throw java.security.GeneralSecurityException("pin_key_not_hardware_backed")241        }242        createdAliases += alias243        return key244    }245246    @Suppress("NewApi")247    private fun generateHmacKey(alias: String, limitedUse: Boolean, createdAliases: MutableList<String>): SecretKey {248        val strongBoxPreferred = Build.VERSION.SDK_INT >= Build.VERSION_CODES.P &&249            appContext.packageManager.hasSystemFeature(PackageManager.FEATURE_STRONGBOX_KEYSTORE)250        val key = try {251            generateHmacKey(alias, limitedUse, strongBoxPreferred)252        } catch (_: StrongBoxUnavailableException) {253            deleteKey(alias)254            generateHmacKey(alias, limitedUse, false)255        }256        if (!isHardwareBacked(key)) {257            deleteKey(alias)258            throw java.security.GeneralSecurityException("pin_key_not_hardware_backed")259        }260        createdAliases += alias261        return key262    }263264    private fun generateAesKey(alias: String, strongBox: Boolean): SecretKey {265        val builder = KeyGenParameterSpec.Builder(266            alias,267            KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT,268        )269            .setBlockModes(KeyProperties.BLOCK_MODE_GCM)270            .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)271            .setKeySize(256)272        if (strongBox && Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) builder.setIsStrongBoxBacked(true)273        val generator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, KEYSTORE)274        generator.init(builder.build())275        return generator.generateKey()276    }277278    private fun generateHmacKey(alias: String, limitedUse: Boolean, strongBox: Boolean): SecretKey {279        val builder = KeyGenParameterSpec.Builder(alias, KeyProperties.PURPOSE_SIGN)280            .setDigests(KeyProperties.DIGEST_SHA256)281            .setKeySize(256)282        if (limitedUse && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) builder.setMaxUsageCount(1)283        if (strongBox && Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) builder.setIsStrongBoxBacked(true)284        val generator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_HMAC_SHA256, KEYSTORE)285        generator.init(builder.build())286        return generator.generateKey()287    }288289    private fun hmac(key: SecretKey, input: ByteArray): ByteArray =290        Mac.getInstance("HmacSHA256").apply { init(key) }.doFinal(input)291292    private fun existingSecretKey(alias: String): SecretKey? = runCatching {293        val keyStore = KeyStore.getInstance(KEYSTORE).apply { load(null) }294        (keyStore.getEntry(alias, null) as? KeyStore.SecretKeyEntry)?.secretKey295    }.getOrNull()296297    private fun isHardwareBacked(key: SecretKey): Boolean = runCatching {298        val factory = SecretKeyFactory.getInstance(key.algorithm, KEYSTORE)299        val info = factory.getKeySpec(key, KeyInfo::class.java) as KeyInfo300        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {301            info.securityLevel == KeyProperties.SECURITY_LEVEL_TRUSTED_ENVIRONMENT ||302                info.securityLevel == KeyProperties.SECURITY_LEVEL_STRONGBOX303        } else {304            @Suppress("DEPRECATION")305            info.isInsideSecureHardware306        }307    }.getOrDefault(false)308309    private fun loadAttemptAliases(): List<String> =310        prefs.getString(KEY_ATTEMPT_ALIASES, null)311            ?.split(DELIMITER)312            ?.filter { it.startsWith(ATTEMPT_ALIAS_PREFIX) }313            ?: emptyList()314315    private fun deleteKey(alias: String) {316        runCatching { KeyStore.getInstance(KEYSTORE).apply { load(null) }.deleteEntry(alias) }317    }318319    private fun clearLegacyConfiguration() {320        prefs.edit()321            .remove("peach_pin_salt")322            .remove("peach_pin_verifier")323            .remove("peach_pin_wrapped_key")324            .remove("peach_pin_preferred")325            .commit()326    }327328    private fun b64(bytes: ByteArray): String = Base64.getEncoder().withoutPadding().encodeToString(bytes)329    private fun decode(value: String): ByteArray = Base64.getDecoder().decode(value)330331    private companion object {332        const val FORMAT_VERSION = 2333        const val PREFS = "peach_vault_store"334        const val KEY_FORMAT = "peach_pin_format"335        const val KEY_SALT = "peach_pin_salt_v2"336        const val KEY_VERIFIER = "peach_pin_verifier_v2"337        const val KEY_WRAPPED_KEY = "peach_pin_wrapped_key_v2"338        const val KEY_WRAPPED_IV = "peach_pin_wrapped_iv_v2"339        const val KEY_ATTEMPT_ALIASES = "peach_pin_attempt_aliases_v2"340        const val KEY_PREFERRED = "peach_pin_preferred_v2"341        const val KEYSTORE = "AndroidKeyStore"342        const val VAULT_ALIAS = "peach_pin_vault_key_v2"343        const val VERIFIER_ALIAS = "peach_pin_verifier_key_v2"344        const val ATTEMPT_ALIAS_PREFIX = "peach_pin_attempt_v2_"345        const val ATTEMPT_GATE_COUNT = 5346        const val TRANSFORM = "AES/GCM/NoPadding"347        const val TAG_BITS = 128348        const val DELIMITER = ","349        val ATTEMPT_CHALLENGE = "peach_pin_attempt_gate_v2".toByteArray(Charsets.UTF_8)350    }351}