Skip to content

Commit 92cb201

Browse files
committed
fix(ios): remove erroneous keychain cleanup causing data loss on upgrade
The clearAllKeychainEntriesSync() function was incorrectly wiping all Keychain entries when upgrading from v1.2.0 to v1.3.0+. The function's premise was flawed: - v1.2.0 used kSecAttrAccessibleWhenUnlockedThisDeviceOnly, which is NOT biometric protection (just "accessible when unlocked") - Biometric auth in v1.3.0+ uses LAContext, a completely separate API - There were no "old biometric prompts" to prevent Only users upgrading FROM v1.2.0 were affected. In-between version upgrades (e.g., v1.3.0→v1.4.0→v1.4.1) were safe because the cleanup tracked its execution via NSUserDefaults BIOMETRIC_CLEANUP_KEY flag, running only once on first launch after the v1.2.0 upgrade. The legitimate cleanupOrphanedKeychainEntries() function (for handling app reinstalls) remains unchanged.
1 parent 3c2acca commit 92cb201

File tree

5 files changed

+23
-47
lines changed

5 files changed

+23
-47
lines changed

CHANGELOG.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,25 @@
22

33
All notable changes to KSafe will be documented in this file.
44

5+
## [1.4.2] - 2025-01-26
6+
7+
### Fixed
8+
9+
- **Critical iOS data loss on upgrade from v1.2.0** - Removed erroneous `clearAllKeychainEntriesSync()` function that was wiping all Keychain entries on first launch
10+
- **Root cause**: The function was based on a flawed premise that v1.2.0 stored "biometric-protected" Keychain entries that needed cleanup
11+
- **Reality**: v1.2.0 used `kSecAttrAccessibleWhenUnlockedThisDeviceOnly` which is NOT biometric protection—it simply means "accessible when device is unlocked"
12+
- **Impact**: Users upgrading from v1.2.0 to v1.3.0+ lost all their encrypted data unnecessarily
13+
- **Fix**: Removed the one-time cleanup that ran on startup. The legitimate `cleanupOrphanedKeychainEntries()` function (which removes Keychain keys without matching DataStore entries after app reinstall) remains intact
14+
- **Who was affected**: Only users who upgraded FROM v1.2.0 TO any version between v1.3.0–v1.4.1. Users who started fresh on v1.3.0+ or upgraded between v1.3.x/v1.4.x versions were NOT affected
15+
- **Why in-between upgrades were safe**: The cleanup used NSUserDefaults to track execution via `BIOMETRIC_CLEANUP_KEY`. Once it ran (on first launch after upgrading from v1.2.0), the flag was set and subsequent version upgrades (e.g., v1.3.0→v1.4.0→v1.4.1) skipped the cleanup entirely. The damage only occurred on that single first upgrade from v1.2.0
16+
17+
### Removed
18+
19+
- `clearAllKeychainEntriesSync()` - iOS function that unnecessarily deleted all Keychain entries
20+
- `BIOMETRIC_CLEANUP_KEY` constant - No longer needed without the erroneous cleanup
21+
22+
---
23+
524
## [1.4.1] - 2025-01-21
625

726
### Performance Improvements

README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -53,8 +53,8 @@ That's it. Your data is now AES-256-GCM encrypted with keys stored in Android Ke
5353

5454
```kotlin
5555
// commonMain or Android-only build.gradle(.kts)
56-
implementation("eu.anifantakis:ksafe:1.4.1")
57-
implementation("eu.anifantakis:ksafe-compose:1.4.1") // ← Compose state (optional)
56+
implementation("eu.anifantakis:ksafe:1.4.2")
57+
implementation("eu.anifantakis:ksafe-compose:1.4.2) // ← Compose state (optional)
5858
```
5959
6060
> Skip `ksafe-compose` if your project doesn't use Jetpack Compose, or if you don't intend to use the library's `mutableStateOf` persistence option

ksafe-compose/build.gradle.kts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ plugins {
1111

1212
// Set the same group and version as your main library
1313
group = "eu.anifantakis"
14-
version = "1.4.1"
14+
version = "1.4.2"
1515

1616
kotlin {
1717
androidLibrary {

ksafe/build.gradle.kts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ plugins {
1010
}
1111

1212
group = "eu.anifantakis"
13-
version = "1.4.1"
13+
version = "1.4.2"
1414

1515
kotlin {
1616
androidTarget {

ksafe/src/iosMain/kotlin/eu/anifantakis/lib/ksafe/KSafe.ios.kt

Lines changed: 0 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -124,7 +124,6 @@ actual class KSafe(
124124
@PublishedApi
125125
internal const val KEY_PREFIX = "eu.anifantakis.ksafe"
126126
private const val INSTALLATION_ID_KEY = "ksafe_installation_id"
127-
private const val BIOMETRIC_CLEANUP_KEY = "ksafe_biometric_cleanup_done"
128127

129128
/**
130129
* Checks if running on iOS Simulator (no biometric hardware available).
@@ -386,55 +385,13 @@ actual class KSafe(
386385
registerAppleProvider()
387386
forceAesGcmRegistration()
388387

389-
// CRITICAL: Clear ALL keychain entries synchronously on startup.
390-
// This ensures old biometric-protected entries are removed before any property access.
391-
// Data encrypted with old keys will be lost, but this is necessary to prevent
392-
// biometric prompts from old library versions.
393-
clearAllKeychainEntriesSync()
394-
395388
// HYBRID CACHE: Start Background Preload immediately.
396389
// If this finishes before the user calls getDirect, the cache will be ready instantly.
397390
if (!lazyLoad) {
398391
startBackgroundCollector()
399392
}
400393
}
401394

402-
/**
403-
* Synchronously clears ALL keychain entries for this KSafe instance (ONE TIME ONLY).
404-
* This prevents old biometric-protected entries from triggering unwanted prompts.
405-
* Uses NSUserDefaults to track if cleanup has been performed.
406-
*/
407-
@OptIn(ExperimentalForeignApi::class)
408-
private fun clearAllKeychainEntriesSync() {
409-
val userDefaults = platform.Foundation.NSUserDefaults.standardUserDefaults
410-
val cleanupKey = fileName?.let { "${BIOMETRIC_CLEANUP_KEY}_$it" } ?: BIOMETRIC_CLEANUP_KEY
411-
412-
// Check if cleanup has already been done
413-
if (userDefaults.boolForKey(cleanupKey)) {
414-
return
415-
}
416-
417-
// Delete all keychain entries for our service
418-
memScoped {
419-
val query = CFDictionaryCreateMutable(
420-
kCFAllocatorDefault,
421-
0,
422-
null,
423-
null
424-
).apply {
425-
CFDictionarySetValue(this, kSecClass, kSecClassGenericPassword)
426-
CFDictionarySetValue(this, kSecAttrService, CFBridgingRetain(SERVICE_NAME))
427-
}
428-
429-
SecItemDelete(query)
430-
CFRelease(query as CFTypeRef?)
431-
}
432-
433-
// Mark cleanup as done
434-
userDefaults.setBool(true, forKey = cleanupKey)
435-
userDefaults.synchronize()
436-
}
437-
438395
private fun startBackgroundCollector() {
439396
CoroutineScope(Dispatchers.Default + SupervisorJob()).launch {
440397
dataStore.data.collect { updateCache(it) }

0 commit comments

Comments
 (0)