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 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 123124125126 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 145 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 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 197 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}