106d9ea293aa

measure single-use Keystore support instead of trusting the feature flags (v2.14.76)

isSupported() gated PIN setup on FEATURE_KEYSTORE_LIMITED_USE_KEY / SINGLE_USE_KEY. Measured on a Galaxy Z Fold 6 (SM-F966U1, SDK 36), those flags are absent while setMaxUsageCount(1) HMAC keys are fully enforced in both StrongBox and TEE: the first MAC succeeds and the key is then gone, whereas a non-limited-use key survives a second MAC. The flags therefore disabled PIN unlock outright on hardware that supports it. isSupported() now runs the enforcement probe that configure() already required and caches the result per store instance, so SettingsViewModel's one call site pays a single key generation. configure() drops its now-redundant explicit probe, and the probe itself drops its isSupported() guard to break the circular dependency. The Settings error copy no longer names the feature flags. Verification on the Fold: PinUnlockStoreHardwareTest 6 passed / 2 skipped, with strongbox_tier_is_used_when_device_advertises_strongbox, wrong_pin_exhaustion_requires_master_password_replenishment, v3_envelope_survives_store_reinstantiation, and the single-use enforcement probe all green; the two skips require StrongBox-absent and unsupported hardware. Full JVM suite 1015 tests / 0 failures / 0 errors / 2 skipped; lintDebug clean; assembleDebug and debug androidTest sources compile.

time
2026-08-31 13:53 UTC · 1 month ago
machine
koh2
agent
hy4-preview
session
pin store device verification
parent
c0543fb5cc99
3 files VERSION+1 −1app/src/main/java/com/peachpasswords/ui/settings/SettingsViewModel.kt+1 −1app/src/main/java/com/peachpasswords/vault/PinUnlockStore.kt+18 −9
VERSION+1 −1
@@ -1 +1 @@1-2.14.751+2.14.76

view file

app/src/main/java/com/peachpasswords/ui/settings/SettingsViewModel.kt+1 −1
@@ -344,7 +344,7 @@344344     }345345 346346     suspend fun savePin(masterPassword: String, pin: String): String? {347-        if (!pinUnlock.isSupported()) return "PIN unlock requires Android hardware limited-use key support"347+        if (!pinUnlock.isSupported()) return "PIN unlock needs hardware that limits PIN attempts"348348         if (masterPassword.isBlank()) return "enter your master password"349349         pinValidationError(pin)?.let { return it }350350         val masterMatches = withContext(Dispatchers.Default) {

view file

app/src/main/java/com/peachpasswords/vault/PinUnlockStore.kt+18 −9
@@ -46,6 +46,7 @@4646 ) {4747     private val appContext = context.applicationContext4848     private val prefs = appContext.getSharedPreferences(PREFS, Context.MODE_PRIVATE)49+    private var supported: Boolean? = null4950 5051     init {5152         // The v1 wrapper was directly brute-forceable from a copied prefs file.@@ -59,10 +60,19 @@5960         }6061     }6162 62-    /** New PIN setup is safe only where Android reports hardware limited-use keys. */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))63+    /**64+     * Whether this device can host a PIN at all. Measured by probing the Keystore65+     * rather than by reading FEATURE_KEYSTORE_LIMITED_USE_KEY / SINGLE_USE_KEY:66+     * a Galaxy Z Fold 6 on SDK 36 enforces max-usage-1 HMAC keys in both67+     * StrongBox and TEE while declaring neither feature, so the flags would68+     * disable PIN unlock on hardware that supports it outright.69+     */70+    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+    }6676 6777     fun isConfigured(): Boolean =6878         prefs.getInt(KEY_FORMAT, 0) == FORMAT_VERSION &&@@ -85,7 +95,6 @@8595 8696     fun configure(pin: String, vaultKey: ByteArray): Boolean {8797         if (!isSupported() || pinValidationError(pin) != null || vaultKey.size != VaultCrypto.KEY_SIZE) return false88-        if (!probeLimitedUseEnforcement()) return false8998         clear()9099 91100         var salt: ByteArray? = null@@ -173,12 +182,12 @@173182         loadAttemptAliases().none { existingSecretKey(it) != null }174183 175184     /**176-     * Prove this device actually enforces a one-use hardware HMAC key instead177-     * of trusting its feature flag. PIN setup proceeds only when the first MAC178-     * succeeds and the same key is then unavailable or rejects a second MAC.185+     * Prove this device actually enforces a one-use hardware HMAC key instead of186+     * trusting its feature flag: the first MAC must succeed and the same key must187+     * then be unavailable or reject a second MAC. Generation also requires a188+     * hardware-backed key, so a software-only provider fails the probe.179189      */180190     internal fun probeLimitedUseEnforcement(): Boolean {181-        if (!isSupported()) return false182191         val alias = "$PROBE_ALIAS_PREFIX${UUID.randomUUID()}"183192         val createdAliases = mutableListOf<String>()184193         return try {

view file