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 if (prefs.getInt(KEY_FORMAT, 0) != FORMAT_VERSION) clearKnownKeystoreAliases()56 }5758 59 fun isSupported(): Boolean = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S &&60 (appContext.packageManager.hasSystemFeature(PackageManager.FEATURE_KEYSTORE_LIMITED_USE_KEY) ||61 appContext.packageManager.hasSystemFeature(PackageManager.FEATURE_KEYSTORE_SINGLE_USE_KEY))6263 fun isConfigured(): Boolean =64 prefs.getInt(KEY_FORMAT, 0) == FORMAT_VERSION &&65 prefs.contains(KEY_SALT) &&66 prefs.contains(KEY_VERIFIER) &&67 prefs.contains(KEY_WRAPPED_KEY) &&68 prefs.contains(KEY_WRAPPED_IV) &&69 existingSecretKey(VAULT_ALIAS) != null &&70 existingSecretKey(VERIFIER_ALIAS) != null7172 fun isPinPreferred(): Boolean = prefs.getBoolean(KEY_PREFERRED, false)7374 fun markPinUsed() {75 prefs.edit().putBoolean(KEY_PREFERRED, true).apply()76 }7778 fun markPasswordUsed() {79 prefs.edit().putBoolean(KEY_PREFERRED, false).apply()80 }8182 fun configure(pin: String, vaultKey: ByteArray): Boolean {83 if (!isSupported() || pinValidationError(pin) != null || vaultKey.size != VaultCrypto.KEY_SIZE) return false84 clear()8586 var salt: ByteArray? = null87 var pinKey: ByteArray? = null88 var verifier: ByteArray? = null89 var wrapped: ByteArray? = null90 val createdAliases = mutableListOf<String>()91 return try {92 val vaultKeystoreKey = generateAesKey(VAULT_ALIAS, createdAliases)93 val verifierKeystoreKey = generateHmacKey(VERIFIER_ALIAS, limitedUse = false, createdAliases)94 salt = VaultCrypto.generateSalt()95 pinKey = VaultCrypto.deriveKey(pin, salt)96 verifier = hmac(verifierKeystoreKey, pinKey)9798 val cipher = Cipher.getInstance(TRANSFORM).apply { init(Cipher.ENCRYPT_MODE, vaultKeystoreKey) }99 wrapped = cipher.doFinal(vaultKey)100 val attemptAliases = createAttemptGates(createdAliases)101102 val saved = prefs.edit()103 .putInt(KEY_FORMAT, FORMAT_VERSION)104 .putString(KEY_SALT, b64(salt!!))105 .putString(KEY_VERIFIER, b64(verifier!!))106 .putString(KEY_WRAPPED_KEY, b64(wrapped!!))107 .putString(KEY_WRAPPED_IV, b64(cipher.iv))108 .putString(KEY_ATTEMPT_ALIASES, attemptAliases.joinToString(DELIMITER))109 .putBoolean(KEY_PREFERRED, false)110 .commit()111 if (!saved) throw java.io.IOException("pin_storage_commit_failed")112 true113 } catch (_: Exception) {114 createdAliases.forEach(::deleteKey)115 clear()116 false117 } finally {118 salt?.let(VaultCrypto::zero)119 pinKey?.let(VaultCrypto::zero)120 verifier?.let(VaultCrypto::zero)121 wrapped?.let(VaultCrypto::zero)122 }123 }124125 126127128129 fun unlockVaultKey(pin: String): ByteArray? {130 if (pinValidationError(pin) != null || !isConfigured() || !consumeAttemptGate()) return null131 var salt: ByteArray? = null132 var expected: ByteArray? = null133 var pinKey: ByteArray? = null134 var actual: ByteArray? = null135 var wrapped: ByteArray? = null136 var iv: ByteArray? = null137 return try {138 salt = prefs.getString(KEY_SALT, null)?.let(::decode) ?: return null139 expected = prefs.getString(KEY_VERIFIER, null)?.let(::decode) ?: return null140 val verifierKey = existingSecretKey(VERIFIER_ALIAS) ?: return null141 pinKey = VaultCrypto.deriveKey(pin, salt!!)142 actual = hmac(verifierKey, pinKey)143 if (!MessageDigest.isEqual(expected, actual)) return null144145 wrapped = prefs.getString(KEY_WRAPPED_KEY, null)?.let(::decode) ?: return null146 iv = prefs.getString(KEY_WRAPPED_IV, null)?.let(::decode) ?: return null147 val vaultKey = Cipher.getInstance(TRANSFORM).run {148 init(Cipher.DECRYPT_MODE, existingSecretKey(VAULT_ALIAS) ?: return null, GCMParameterSpec(TAG_BITS, iv!!))149 doFinal(wrapped!!)150 }151 152 153 replenishAttemptGates()154 vaultKey155 } catch (_: Exception) {156 null157 } finally {158 salt?.let(VaultCrypto::zero)159 expected?.let(VaultCrypto::zero)160 pinKey?.let(VaultCrypto::zero)161 actual?.let(VaultCrypto::zero)162 wrapped?.let(VaultCrypto::zero)163 iv?.let(VaultCrypto::zero)164 }165 }166167 fun areAttemptsExhausted(): Boolean = isConfigured() &&168 loadAttemptAliases().none { existingSecretKey(it) != null }169170 171 fun replenishAttemptGates() {172 if (!isConfigured()) return173 val oldAliases = loadAttemptAliases()174 val newAliases = mutableListOf<String>()175 try {176 val attemptAliases = createAttemptGates(newAliases)177 if (!prefs.edit().putString(KEY_ATTEMPT_ALIASES, attemptAliases.joinToString(DELIMITER)).commit()) {178 throw java.io.IOException("pin_attempt_gate_commit_failed")179 }180 oldAliases.forEach(::deleteKey)181 } catch (_: Exception) {182 newAliases.forEach(::deleteKey)183 }184 }185186 fun clear() {187 loadAttemptAliases().forEach(::deleteKey)188 listOf(VAULT_ALIAS, VERIFIER_ALIAS).forEach(::deleteKey)189 clearLegacyConfiguration()190 prefs.edit()191 .remove(KEY_FORMAT)192 .remove(KEY_SALT)193 .remove(KEY_VERIFIER)194 .remove(KEY_WRAPPED_KEY)195 .remove(KEY_WRAPPED_IV)196 .remove(KEY_ATTEMPT_ALIASES)197 .remove(KEY_PREFERRED)198 .commit()199 }200201 private fun consumeAttemptGate(): Boolean {202 val aliases = loadAttemptAliases()203 for (alias in aliases) {204 val key = existingSecretKey(alias) ?: continue205 try {206 207 208 hmac(key, ATTEMPT_CHALLENGE)209 deleteKey(alias)210 prefs.edit().putString(211 KEY_ATTEMPT_ALIASES,212 aliases.filterNot { it == alias }.joinToString(DELIMITER),213 ).commit()214 return true215 } catch (_: KeyPermanentlyInvalidatedException) {216 deleteKey(alias)217 } catch (_: Exception) {218 deleteKey(alias)219 }220 }221 return false222 }223224 private fun createAttemptGates(createdAliases: MutableList<String>): List<String> {225 repeat(ATTEMPT_GATE_COUNT) {226 val alias = "$ATTEMPT_ALIAS_PREFIX${UUID.randomUUID()}"227 generateHmacKey(alias, limitedUse = true, createdAliases)228 }229 val aliases = createdAliases.filter { it.startsWith(ATTEMPT_ALIAS_PREFIX) }230 if (aliases.size != ATTEMPT_GATE_COUNT) throw java.io.IOException("pin_attempt_gate_count")231 return aliases232 }233234 @Suppress("NewApi")235 private fun generateAesKey(alias: String, createdAliases: MutableList<String>): SecretKey {236 existingSecretKey(alias)?.let { key ->237 if (isHardwareBacked(key)) return key238 deleteKey(alias)239 }240 val strongBoxPreferred = Build.VERSION.SDK_INT >= Build.VERSION_CODES.P &&241 appContext.packageManager.hasSystemFeature(PackageManager.FEATURE_STRONGBOX_KEYSTORE)242 val key = try {243 generateAesKey(alias, strongBoxPreferred)244 } catch (_: StrongBoxUnavailableException) {245 deleteKey(alias)246 generateAesKey(alias, false)247 }248 if (!isHardwareBacked(key)) {249 deleteKey(alias)250 throw java.security.GeneralSecurityException("pin_key_not_hardware_backed")251 }252 createdAliases += alias253 return key254 }255256 @Suppress("NewApi")257 private fun generateHmacKey(alias: String, limitedUse: Boolean, createdAliases: MutableList<String>): SecretKey {258 val strongBoxPreferred = Build.VERSION.SDK_INT >= Build.VERSION_CODES.P &&259 appContext.packageManager.hasSystemFeature(PackageManager.FEATURE_STRONGBOX_KEYSTORE)260 val key = try {261 generateHmacKey(alias, limitedUse, strongBoxPreferred)262 } catch (_: StrongBoxUnavailableException) {263 deleteKey(alias)264 generateHmacKey(alias, limitedUse, false)265 }266 if (!isHardwareBacked(key)) {267 deleteKey(alias)268 throw java.security.GeneralSecurityException("pin_key_not_hardware_backed")269 }270 createdAliases += alias271 return key272 }273274 private fun generateAesKey(alias: String, strongBox: Boolean): SecretKey {275 val builder = KeyGenParameterSpec.Builder(276 alias,277 KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT,278 )279 .setBlockModes(KeyProperties.BLOCK_MODE_GCM)280 .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)281 .setKeySize(256)282 if (strongBox && Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) builder.setIsStrongBoxBacked(true)283 val generator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, KEYSTORE)284 generator.init(builder.build())285 return generator.generateKey()286 }287288 private fun generateHmacKey(alias: String, limitedUse: Boolean, strongBox: Boolean): SecretKey {289 val builder = KeyGenParameterSpec.Builder(alias, KeyProperties.PURPOSE_SIGN)290 .setDigests(KeyProperties.DIGEST_SHA256)291 .setKeySize(256)292 if (limitedUse && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) builder.setMaxUsageCount(1)293 if (strongBox && Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) builder.setIsStrongBoxBacked(true)294 val generator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_HMAC_SHA256, KEYSTORE)295 generator.init(builder.build())296 return generator.generateKey()297 }298299 private fun hmac(key: SecretKey, input: ByteArray): ByteArray =300 Mac.getInstance("HmacSHA256").apply { init(key) }.doFinal(input)301302 private fun existingSecretKey(alias: String): SecretKey? = runCatching {303 val keyStore = KeyStore.getInstance(KEYSTORE).apply { load(null) }304 (keyStore.getEntry(alias, null) as? KeyStore.SecretKeyEntry)?.secretKey305 }.getOrNull()306307 private fun isHardwareBacked(key: SecretKey): Boolean = runCatching {308 val factory = SecretKeyFactory.getInstance(key.algorithm, KEYSTORE)309 val info = factory.getKeySpec(key, KeyInfo::class.java) as KeyInfo310 if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {311 info.securityLevel == KeyProperties.SECURITY_LEVEL_TRUSTED_ENVIRONMENT ||312 info.securityLevel == KeyProperties.SECURITY_LEVEL_STRONGBOX313 } else {314 @Suppress("DEPRECATION")315 info.isInsideSecureHardware316 }317 }.getOrDefault(false)318319 private fun loadAttemptAliases(): List<String> =320 prefs.getString(KEY_ATTEMPT_ALIASES, null)321 ?.split(DELIMITER)322 ?.filter { it.startsWith(ATTEMPT_ALIAS_PREFIX) }323 ?: emptyList()324325 private fun deleteKey(alias: String) {326 runCatching { KeyStore.getInstance(KEYSTORE).apply { load(null) }.deleteEntry(alias) }327 }328329 private fun clearKnownKeystoreAliases() {330 runCatching {331 KeyStore.getInstance(KEYSTORE).apply { load(null) }.aliases().toList()332 .filter { it == VAULT_ALIAS || it == VERIFIER_ALIAS || it.startsWith(ATTEMPT_ALIAS_PREFIX) }333 .forEach(::deleteKey)334 }335 }336337 private fun clearLegacyConfiguration() {338 prefs.edit()339 .remove("peach_pin_salt")340 .remove("peach_pin_verifier")341 .remove("peach_pin_wrapped_key")342 .remove("peach_pin_preferred")343 .commit()344 }345346 private fun b64(bytes: ByteArray): String = Base64.getEncoder().withoutPadding().encodeToString(bytes)347 private fun decode(value: String): ByteArray = Base64.getDecoder().decode(value)348349 private companion object {350 const val FORMAT_VERSION = 2351 const val PREFS = "peach_vault_store"352 const val KEY_FORMAT = "peach_pin_format"353 const val KEY_SALT = "peach_pin_salt_v2"354 const val KEY_VERIFIER = "peach_pin_verifier_v2"355 const val KEY_WRAPPED_KEY = "peach_pin_wrapped_key_v2"356 const val KEY_WRAPPED_IV = "peach_pin_wrapped_iv_v2"357 const val KEY_ATTEMPT_ALIASES = "peach_pin_attempt_aliases_v2"358 const val KEY_PREFERRED = "peach_pin_preferred_v2"359 const val KEYSTORE = "AndroidKeyStore"360 const val VAULT_ALIAS = "peach_pin_vault_key_v2"361 const val VERIFIER_ALIAS = "peach_pin_verifier_key_v2"362 const val ATTEMPT_ALIAS_PREFIX = "peach_pin_attempt_v2_"363 const val ATTEMPT_GATE_COUNT = 5364 const val TRANSFORM = "AES/GCM/NoPadding"365 const val TAG_BITS = 128366 const val DELIMITER = ","367 val ATTEMPT_CHALLENGE = "peach_pin_attempt_gate_v2".toByteArray(Charsets.UTF_8)368 }369}