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}3334353637383940414243@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 52 53 54 clearLegacyConfiguration()55 }5657 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 121122123124 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 143 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 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 195 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}