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)49 private var supported: Boolean? = null5051 init {52 53 54 55 clearLegacyConfiguration()56 when (prefs.getInt(KEY_FORMAT, 0)) {57 FORMAT_VERSION -> Unit58 0 -> clearKnownKeystoreAliases()59 else -> clear()60 }61 }6263 64656667686970 fun isSupported(): Boolean {71 supported?.let { return it }72 val measured = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && probeLimitedUseEnforcement()73 supported = measured74 return measured75 }7677 fun isConfigured(): Boolean =78 prefs.getInt(KEY_FORMAT, 0) == FORMAT_VERSION &&79 prefs.contains(KEY_SALT) &&80 prefs.contains(KEY_VERIFIER) &&81 prefs.contains(KEY_WRAPPED_KEY) &&82 prefs.contains(KEY_WRAPPED_IV) &&83 existingSecretKey(VAULT_ALIAS) != null &&84 existingSecretKey(VERIFIER_ALIAS) != null8586 fun isPinPreferred(): Boolean = prefs.getBoolean(KEY_PREFERRED, false)8788 fun markPinUsed() {89 prefs.edit().putBoolean(KEY_PREFERRED, true).apply()90 }9192 fun markPasswordUsed() {93 prefs.edit().putBoolean(KEY_PREFERRED, false).apply()94 }9596 fun configure(pin: String, vaultKey: ByteArray): Boolean {97 if (!isSupported() || pinValidationError(pin) != null || vaultKey.size != VaultCrypto.KEY_SIZE) return false98 clear()99100 var salt: ByteArray? = null101 var pinKey: ByteArray? = null102 var verifier: ByteArray? = null103 var wrapped: ByteArray? = null104 val createdAliases = mutableListOf<String>()105 return try {106 val vaultKeystoreKey = generateAesKey(VAULT_ALIAS, createdAliases)107 val verifierKeystoreKey = generateHmacKey(VERIFIER_ALIAS, limitedUse = false, createdAliases)108 salt = VaultCrypto.generateSalt()109 pinKey = VaultCrypto.deriveKey(pin, salt)110 verifier = hmac(verifierKeystoreKey, pinKey)111112 val cipher = Cipher.getInstance(TRANSFORM).apply { init(Cipher.ENCRYPT_MODE, vaultKeystoreKey) }113 wrapped = cipher.doFinal(vaultKey)114 val attemptAliases = createAttemptGates(createdAliases)115116 val saved = prefs.edit()117 .putInt(KEY_FORMAT, FORMAT_VERSION)118 .putString(KEY_SALT, b64(salt!!))119 .putString(KEY_VERIFIER, b64(verifier!!))120 .putString(KEY_WRAPPED_KEY, b64(wrapped!!))121 .putString(KEY_WRAPPED_IV, b64(cipher.iv))122 .putString(KEY_ATTEMPT_ALIASES, attemptAliases.joinToString(DELIMITER))123 .putBoolean(KEY_PREFERRED, false)124 .commit()125 if (!saved) throw java.io.IOException("pin_storage_commit_failed")126 true127 } catch (_: Exception) {128 createdAliases.forEach(::deleteKey)129 clear()130 false131 } finally {132 salt?.let(VaultCrypto::zero)133 pinKey?.let(VaultCrypto::zero)134 verifier?.let(VaultCrypto::zero)135 wrapped?.let(VaultCrypto::zero)136 }137 }138139 140141142143 fun unlockVaultKey(pin: String): ByteArray? {144 if (pinValidationError(pin) != null || !isConfigured() || !consumeAttemptGate()) return null145 var salt: ByteArray? = null146 var expected: ByteArray? = null147 var pinKey: ByteArray? = null148 var actual: ByteArray? = null149 var wrapped: ByteArray? = null150 var iv: ByteArray? = null151 return try {152 salt = prefs.getString(KEY_SALT, null)?.let(::decode) ?: return null153 expected = prefs.getString(KEY_VERIFIER, null)?.let(::decode) ?: return null154 val verifierKey = existingSecretKey(VERIFIER_ALIAS) ?: return null155 pinKey = VaultCrypto.deriveKey(pin, salt!!)156 actual = hmac(verifierKey, pinKey)157 if (!MessageDigest.isEqual(expected, actual)) return null158159 wrapped = prefs.getString(KEY_WRAPPED_KEY, null)?.let(::decode) ?: return null160 iv = prefs.getString(KEY_WRAPPED_IV, null)?.let(::decode) ?: return null161 val vaultKey = Cipher.getInstance(TRANSFORM).run {162 init(Cipher.DECRYPT_MODE, existingSecretKey(VAULT_ALIAS) ?: return null, GCMParameterSpec(TAG_BITS, iv!!))163 doFinal(wrapped!!)164 }165 166 167 replenishAttemptGates()168 vaultKey169 } catch (_: Exception) {170 null171 } finally {172 salt?.let(VaultCrypto::zero)173 expected?.let(VaultCrypto::zero)174 pinKey?.let(VaultCrypto::zero)175 actual?.let(VaultCrypto::zero)176 wrapped?.let(VaultCrypto::zero)177 iv?.let(VaultCrypto::zero)178 }179 }180181 fun areAttemptsExhausted(): Boolean = isConfigured() &&182 loadAttemptAliases().none { existingSecretKey(it) != null }183184 185186187188189190 internal fun probeLimitedUseEnforcement(): Boolean {191 val alias = "$PROBE_ALIAS_PREFIX${UUID.randomUUID()}"192 val createdAliases = mutableListOf<String>()193 return try {194 val key = generateHmacKey(alias, limitedUse = true, createdAliases)195 hmac(key, ATTEMPT_CHALLENGE).let(VaultCrypto::zero)196 val remainingKey = existingSecretKey(alias) ?: return true197 val secondUseSucceeded = runCatching {198 hmac(remainingKey, ATTEMPT_CHALLENGE).let(VaultCrypto::zero)199 }.isSuccess200 !secondUseSucceeded201 } catch (_: Exception) {202 false203 } finally {204 deleteKey(alias)205 }206 }207208 209 fun replenishAttemptGates() {210 if (!isConfigured()) return211 val oldAliases = loadAttemptAliases()212 val newAliases = mutableListOf<String>()213 try {214 val attemptAliases = createAttemptGates(newAliases)215 if (!prefs.edit().putString(KEY_ATTEMPT_ALIASES, attemptAliases.joinToString(DELIMITER)).commit()) {216 throw java.io.IOException("pin_attempt_gate_commit_failed")217 }218 oldAliases.forEach(::deleteKey)219 } catch (_: Exception) {220 newAliases.forEach(::deleteKey)221 }222 }223224 fun clear() {225 loadAttemptAliases().forEach(::deleteKey)226 listOf(VAULT_ALIAS, VERIFIER_ALIAS).forEach(::deleteKey)227 clearLegacyConfiguration()228 prefs.edit()229 .remove(KEY_FORMAT)230 .remove(KEY_SALT)231 .remove(KEY_VERIFIER)232 .remove(KEY_WRAPPED_KEY)233 .remove(KEY_WRAPPED_IV)234 .remove(KEY_ATTEMPT_ALIASES)235 .remove(KEY_PREFERRED)236 .commit()237 }238239 private fun consumeAttemptGate(): Boolean {240 val aliases = loadAttemptAliases()241 for (alias in aliases) {242 val key = existingSecretKey(alias) ?: continue243 try {244 245 246 hmac(key, ATTEMPT_CHALLENGE)247 deleteKey(alias)248 prefs.edit().putString(249 KEY_ATTEMPT_ALIASES,250 aliases.filterNot { it == alias }.joinToString(DELIMITER),251 ).commit()252 return true253 } catch (_: KeyPermanentlyInvalidatedException) {254 deleteKey(alias)255 } catch (_: Exception) {256 deleteKey(alias)257 }258 }259 return false260 }261262 private fun createAttemptGates(createdAliases: MutableList<String>): List<String> {263 repeat(ATTEMPT_GATE_COUNT) {264 val alias = "$ATTEMPT_ALIAS_PREFIX${UUID.randomUUID()}"265 generateHmacKey(alias, limitedUse = true, createdAliases)266 }267 val aliases = createdAliases.filter { it.startsWith(ATTEMPT_ALIAS_PREFIX) }268 if (aliases.size != ATTEMPT_GATE_COUNT) throw java.io.IOException("pin_attempt_gate_count")269 return aliases270 }271272 @Suppress("NewApi")273 private fun generateAesKey(alias: String, createdAliases: MutableList<String>): SecretKey {274 existingSecretKey(alias)?.let { key ->275 if (isHardwareBacked(key)) return key276 deleteKey(alias)277 }278 val strongBoxPreferred = Build.VERSION.SDK_INT >= Build.VERSION_CODES.P &&279 appContext.packageManager.hasSystemFeature(PackageManager.FEATURE_STRONGBOX_KEYSTORE)280 val key = try {281 generateAesKey(alias, strongBoxPreferred)282 } catch (_: StrongBoxUnavailableException) {283 deleteKey(alias)284 generateAesKey(alias, false)285 }286 if (!isHardwareBacked(key)) {287 deleteKey(alias)288 throw java.security.GeneralSecurityException("pin_key_not_hardware_backed")289 }290 createdAliases += alias291 return key292 }293294 @Suppress("NewApi")295 private fun generateHmacKey(alias: String, limitedUse: Boolean, createdAliases: MutableList<String>): SecretKey {296 val strongBoxPreferred = Build.VERSION.SDK_INT >= Build.VERSION_CODES.P &&297 appContext.packageManager.hasSystemFeature(PackageManager.FEATURE_STRONGBOX_KEYSTORE)298 val key = try {299 generateHmacKey(alias, limitedUse, strongBoxPreferred)300 } catch (_: StrongBoxUnavailableException) {301 deleteKey(alias)302 generateHmacKey(alias, limitedUse, false)303 }304 if (!isHardwareBacked(key)) {305 deleteKey(alias)306 throw java.security.GeneralSecurityException("pin_key_not_hardware_backed")307 }308 createdAliases += alias309 return key310 }311312 private fun generateAesKey(alias: String, strongBox: Boolean): SecretKey {313 val builder = KeyGenParameterSpec.Builder(314 alias,315 KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT,316 )317 .setBlockModes(KeyProperties.BLOCK_MODE_GCM)318 .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)319 .setKeySize(256)320 if (strongBox && Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) builder.setIsStrongBoxBacked(true)321 val generator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, KEYSTORE)322 generator.init(builder.build())323 return generator.generateKey()324 }325326 private fun generateHmacKey(alias: String, limitedUse: Boolean, strongBox: Boolean): SecretKey {327 val builder = KeyGenParameterSpec.Builder(alias, KeyProperties.PURPOSE_SIGN)328 .setDigests(KeyProperties.DIGEST_SHA256)329 .setKeySize(256)330 if (limitedUse && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) builder.setMaxUsageCount(1)331 if (strongBox && Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) builder.setIsStrongBoxBacked(true)332 val generator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_HMAC_SHA256, KEYSTORE)333 generator.init(builder.build())334 return generator.generateKey()335 }336337 private fun hmac(key: SecretKey, input: ByteArray): ByteArray =338 Mac.getInstance("HmacSHA256").apply { init(key) }.doFinal(input)339340 private fun existingSecretKey(alias: String): SecretKey? = runCatching {341 val keyStore = KeyStore.getInstance(KEYSTORE).apply { load(null) }342 (keyStore.getEntry(alias, null) as? KeyStore.SecretKeyEntry)?.secretKey343 }.getOrNull()344345 private fun isHardwareBacked(key: SecretKey): Boolean = runCatching {346 val factory = SecretKeyFactory.getInstance(key.algorithm, KEYSTORE)347 val info = factory.getKeySpec(key, KeyInfo::class.java) as KeyInfo348 if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {349 info.securityLevel == KeyProperties.SECURITY_LEVEL_TRUSTED_ENVIRONMENT ||350 info.securityLevel == KeyProperties.SECURITY_LEVEL_STRONGBOX351 } else {352 @Suppress("DEPRECATION")353 info.isInsideSecureHardware354 }355 }.getOrDefault(false)356357 private fun loadAttemptAliases(): List<String> =358 prefs.getString(KEY_ATTEMPT_ALIASES, null)359 ?.split(DELIMITER)360 ?.filter { it.startsWith(ATTEMPT_ALIAS_PREFIX) }361 ?: emptyList()362363 private fun deleteKey(alias: String) {364 runCatching { KeyStore.getInstance(KEYSTORE).apply { load(null) }.deleteEntry(alias) }365 }366367 private fun clearKnownKeystoreAliases() {368 runCatching {369 KeyStore.getInstance(KEYSTORE).apply { load(null) }.aliases().toList()370 .filter {371 it == VAULT_ALIAS || it == VERIFIER_ALIAS ||372 it.startsWith(ATTEMPT_ALIAS_PREFIX) || it.startsWith(PROBE_ALIAS_PREFIX)373 }374 .forEach(::deleteKey)375 }376 }377378 private fun clearLegacyConfiguration() {379 prefs.edit()380 .remove("peach_pin_salt")381 .remove("peach_pin_verifier")382 .remove("peach_pin_wrapped_key")383 .remove("peach_pin_preferred")384 .commit()385 }386387 private fun b64(bytes: ByteArray): String = Base64.getEncoder().withoutPadding().encodeToString(bytes)388 private fun decode(value: String): ByteArray = Base64.getDecoder().decode(value)389390 private companion object {391 const val FORMAT_VERSION = 3392 const val PREFS = "peach_vault_store"393 const val KEY_FORMAT = "peach_pin_format"394 const val KEY_SALT = "peach_pin_salt_v2"395 const val KEY_VERIFIER = "peach_pin_verifier_v2"396 const val KEY_WRAPPED_KEY = "peach_pin_wrapped_key_v2"397 const val KEY_WRAPPED_IV = "peach_pin_wrapped_iv_v2"398 const val KEY_ATTEMPT_ALIASES = "peach_pin_attempt_aliases_v2"399 const val KEY_PREFERRED = "peach_pin_preferred_v2"400 const val KEYSTORE = "AndroidKeyStore"401 const val VAULT_ALIAS = "peach_pin_vault_key_v2"402 const val VERIFIER_ALIAS = "peach_pin_verifier_key_v2"403 const val ATTEMPT_ALIAS_PREFIX = "peach_pin_attempt_v2_"404 const val PROBE_ALIAS_PREFIX = "peach_pin_probe_v3_"405 const val ATTEMPT_GATE_COUNT = 5406 const val TRANSFORM = "AES/GCM/NoPadding"407 const val TAG_BITS = 128408 const val DELIMITER = ","409 val ATTEMPT_CHALLENGE = "peach_pin_attempt_gate_v2".toByteArray(Charsets.UTF_8)410 }411}