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 when (prefs.getInt(KEY_FORMAT, 0)) {56 FORMAT_VERSION -> Unit57 0 -> clearKnownKeystoreAliases()58 else -> clear()59 }60 }6162 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 131132133134 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 157 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 176177178179180 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 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 236 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}