
Effortlessly encrypts and persists data using hardware-backed security, offering one-code-path simplicity, seamless integration, and protection for sensitive information like OAuth tokens.
The Universal Persistence Layer: Effortless Enterprise-Grade Security AND Lightning-Fast Plain-Text Storage for Android, iOS, Desktop, and Web.
To see KSafe in action on several scenarios, I invite you to check out my demo application here: Demo CMP App Using KSafe
Check out my own video about how easy it is to adapt KSafe into your project and get seamless encrypted persistence, but also more videos from other content creators.
| Author's Video | Philipp Lackner's Video | Jimmy Plazas's Video |
|---|---|---|
| KSafe - Kotlin Multiplatform Encrypted DataStore Persistence Library | How to Encrypt Local Preferences In KMP With KSafe | Encripta datos localmente en Kotlin Multiplatform con KSafe - Ejemplo + Arquitectura |
library to persist encrypted and unencrypted data in Kotlin Multiplatform.
With simple property delegation, values feel like normal variables — you just read and write them, and KSafe handles the underlying cryptography, caching, and atomic DataStore persistence transparently across all four platforms: Android, iOS, JVM/Desktop, and WASM/JS (Browser).
Think KSafe is overkill for a simple "Dark Mode" toggle? Think again.
By setting mode = KSafeWriteMode.Plain, KSafe completely bypasses the cryptographic engine. What remains is a lightning-fast, zero-boilerplate wrapper around AndroidX DataStore with a concurrent hot-cache.
Setting up raw KMP DataStore requires writing expect/actual file paths across 4 platforms, managing CoroutineScopes, and dealing with async-only Flow reads. KSafe abstracts 100% of that. You get synchronous, O(1) reads backed by asynchronous disk writes—all in one line of code. Unencrypted KSafe writes are actually benchmarked to be faster than native Android SharedPreferences.
Whether you are storing a harmless UI state or a highly sensitive biometric token, KSafe is the only local persistence dependency your KMP app needs.
Here's what that looks like in a real app — Ktor bearer authentication with zero encryption boilerplate:
@Serializable
data class AuthTokens(
val accessToken: String = "",
val refreshToken: String = ""
)
// One line to encrypt, persist, and serialize the whole object
var tokens by ksafe(AuthTokens())
install(Auth) {
bearer {
loadTokens {
// Reads atomic object from hot cache (~0.007ms). No disk. No suspend.
BearerTokens(tokens.accessToken, tokens.refreshToken)
}
refreshTokens {
val newInfo = api.refreshAuth(tokens.refreshToken)
// Atomic update: encrypts & persists as JSON in background (~13μs)
tokens = AuthTokens(
accessToken = newInfo.accessToken,
refreshToken = newInfo.refreshToken
)
BearerTokens(tokens.accessToken, tokens.refreshToken)
}
}
}No explicit encrypt/decrypt calls. No DataStore boilerplate. No runBlocking. Tokens are AES-256-GCM encrypted at rest, served from the hot cache at runtime, and survive process death — all through regular Kotlin property access.
Under the hood, each platform uses its native crypto engine — Android Keystore, iOS Keychain + CryptoKit, JVM's javax.crypto, and browser WebCrypto — unified behind a single API. Values are AES-256-GCM encrypted and persisted to DataStore (or localStorage on WASM). Beyond property delegation, KSafe also offers Compose state integration (ksafe.mutableStateOf()), reactive flows (getFlow() / getStateFlow()), built-in biometric authentication, configurable memory policies, and runtime security detection (root/jailbreak, debugger, emulator) — all out of the box.
// 1. Create instance (Android needs context, others don't)
val ksafe = KSafe(context) // Android
val ksafe = KSafe() // iOS / JVM / WASM
// 2. Store & retrieve with property delegation
var counter by ksafe(0)
counter++ // Auto-encrypted, auto-persisted
// 3. Or use suspend API
viewModelScope.launch {
ksafe.put("user_token", token)
val token = ksafe.get("user_token", "")
}
// 4. Protect actions with biometrics
ksafe.verifyBiometricDirect("Confirm payment") { success ->
if (success) processPayment()
}That's it. Your data is now AES-256-GCM encrypted with keys stored in Android Keystore, iOS Keychain, software-backed on JVM, or WebCrypto on WASM.
// commonMain or Android-only build.gradle(.kts)
implementation("eu.anifantakis:ksafe:1.7.1")
implementation("eu.anifantakis:ksafe-compose:1.7.1") // ← Compose state (optional)Skip
ksafe-composeif your project doesn't use Jetpack Compose, or if you don't intend to use the library'smutableStateOfpersistence option
Note:
kotlinx-serialization-jsonis exposed as a transitive dependency — you do not need to add it manually to your project. KSafe already provides it.
If you want to use the library with data classes, you need to enable Serialization at your project.
Add Serialization definition to your plugins section of your libs.versions.toml
[versions]
kotlin = "2.2.21"
[plugins]
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }and apply it at the same section of your build.gradle.kts file.
plugins {
//...
alias(libs.plugins.kotlin.serialization)
}Koin is the defacto DI solution for Kotlin Multiplatform, and is the ideal tool to provide KSafe as a singleton.
Performance guidance — "prefs" vs "vault": Encryption adds overhead to every write (AES-GCM + Keystore/Keychain round-trip). For data that doesn't need confidentiality — theme preferences, last-visited screen, UI flags — use
mode = KSafeWriteMode.Plainto get SharedPreferences-level speed. Reserve encryption for secrets like tokens, passwords, and PII. The easiest way to enforce this is to create two named singletons:
// ──────────────────────────────────────────────
// common
// ──────────────────────────────────────────────
expect val platformModule: Module
// ──────────────────────────────────────────────
// Android
// ──────────────────────────────────────────────
actual val platformModule = module {
// Fast, unencrypted — for everyday preferences
single(named("prefs")) {
KSafe(
context = androidApplication(),
fileName = "prefs",
memoryPolicy = KSafeMemoryPolicy.PLAIN_TEXT
)
}
// Encrypted — for secrets (tokens, passwords, PII)
single(named("vault")) {
KSafe(
context = androidApplication(),
fileName = "vault"
)
}
}
// ──────────────────────────────────────────────
// iOS
// ──────────────────────────────────────────────
actual val platformModule = module {
single(named("prefs")) {
KSafe(
fileName = "prefs",
memoryPolicy = KSafeMemoryPolicy.PLAIN_TEXT
)
}
single(named("vault")) {
KSafe(
fileName = "vault"
)
}
}
// ──────────────────────────────────────────────
// JVM/Desktop
// ──────────────────────────────────────────────
actual val platformModule = module {
single(named("prefs")) {
KSafe(
fileName = "prefs",
memoryPolicy = KSafeMemoryPolicy.PLAIN_TEXT
)
}
single(named("vault")) {
KSafe(fileName = "vault")
}
}
// ──────────────────────────────────────────────
// WASM — call ksafe.awaitCacheReady() before first encrypted read (see note below)
// ──────────────────────────────────────────────
actual val platformModule = module {
single(named("prefs")) {
KSafe(
fileName = "prefs",
memoryPolicy = KSafeMemoryPolicy.PLAIN_TEXT
)
}
single(named("vault")) {
KSafe(fileName = "vault")
}
}Then inject by name in your ViewModels:
class MyViewModel(
private val prefs: KSafe, // @Named("prefs") — fast, unencrypted
private val vault: KSafe // @Named("vault") — encrypted secrets
) : ViewModel() {
// UI preferences — no encryption overhead
var theme by prefs("dark", mode = KSafeWriteMode.Plain)
var lastScreen by prefs("home", mode = KSafeWriteMode.Plain)
var onboarded by prefs(false, mode = KSafeWriteMode.Plain)
// Secrets — AES-256-GCM encrypted, hardware-backed keys
var authToken by vault("")
var refreshToken by vault("")
var userPin by vault(
"",
mode = KSafeWriteMode.Encrypted(KSafeEncryptedProtection.HARDWARE_ISOLATED)
) // StrongBox / SE
}Of course, if your app only stores secrets you can use a single default instance — the two-instance pattern is a recommendation for apps that mix everyday preferences with sensitive data.
// Single instance (perfectly fine if everything needs encryption)
// Android
actual val platformModule = module {
single { KSafe(androidApplication()) }
}
// iOS / JVM / WASM
actual val platformModule = module {
single { KSafe() }
}WASM/JS: WebCrypto encryption is async-only, so KSafe must finish decrypting its cache before your UI reads any encrypted values. Call
awaitCacheReady()before rendering content.With
startKoin(classic):fun main() { startKoin { modules(sharedModule, platformModule) } val body = document.body ?: return ComposeViewport(body) { var cacheReady by remember { mutableStateOf(false) } LaunchedEffect(Unit) { val ksafe: KSafe = getKoin().get() ksafe.awaitCacheReady() cacheReady = true } if (cacheReady) { App() } } }With
KoinMultiplatformApplication(Compose):fun main() { val body = document.body ?: return ComposeViewport(body) { KoinMultiplatformApplication(config = createKoinConfiguration()) { var cacheReady by remember { mutableStateOf(false) } LaunchedEffect(Unit) { val ksafe: KSafe = getKoin().get() ksafe.awaitCacheReady() cacheReady = true } if (cacheReady) { AppContent() // your app's UI (without KoinMultiplatformApplication wrapper) } } } }With
startKoin, Koin is initialized beforeComposeViewport, sogetKoin()works immediately. WithKoinMultiplatformApplication,awaitCacheReady()must go inside the composable — Koin isn't available until that scope.
Now you're ready to inject KSafe into your ViewModels!
var counter by ksafe(0)Parameters:
defaultValue - must be declared (type is inferred from it)key - if not set, the variable name is used as a keymode (overload) - KSafeWriteMode.Plain or KSafeWriteMode.Encrypted(...) for per-entry controlclass MyViewModel(ksafe: KSafe): ViewModel() {
var counter by ksafe(0)
init {
// then just use it as a regular variable
counter++
}
}Important: The property delegate can ONLY use the default KSafe instance. If you need to use multiple KSafe instances with different file names, you must use the suspend or direct APIs.
var counter by ksafe.mutableStateOf(0)Recomposition-proof and survives process death with zero boilerplate. Requires the ksafe-compose dependency.
class MyViewModel(ksafe: KSafe): ViewModel() {
var counter by ksafe.mutableStateOf(0)
private set
init {
counter++
}
}When you need custom Compose equality semantics, use the advanced overload with policy:
import androidx.compose.runtime.neverEqualPolicy
import androidx.compose.runtime.referentialEqualityPolicy
// Default (recommended): structural equality
var profile by ksafe.mutableStateOf(Profile())
// Persist/recompose only when reference changes
var uiModel by ksafe.mutableStateOf(
defaultValue = UiModel(),
policy = referentialEqualityPolicy()
)
// Always treat assignment as a change (always persists)
var ticks by ksafe.mutableStateOf(
defaultValue = 0,
policy = neverEqualPolicy()
)// inside coroutine / suspend fn
ksafe.put("profile", userProfile) // encrypt & persist
val cached: User = ksafe.get("profile", User())ksafe.putDirect("counter", 42)
val n = ksafe.getDirect("counter", 0)Performance Note: For bulk or concurrent operations, always use the Direct API. The Coroutine API waits for DataStore persistence on each call (~22 ms), while the Direct API returns immediately from the hot cache (~0.022 ms) — that's ~1000x faster.
| API | Read | Write | Best For |
|---|---|---|---|
getDirect/putDirect
|
0.007 ms | 0.022 ms | UI, bulk ops, high throughput |
get/put (suspend) |
0.010 ms | 22 ms | When you must guarantee persistence |
Use KSafeWriteMode when you need encrypted-only options like requireUnlockedDevice:
// Direct API
ksafe.putDirect(
"token",
token,
mode = KSafeWriteMode.Encrypted(
protection = KSafeEncryptedProtection.DEFAULT,
requireUnlockedDevice = true
)
)
// Suspend API
ksafe.put(
"pin",
pin,
mode = KSafeWriteMode.Encrypted(
protection = KSafeEncryptedProtection.HARDWARE_ISOLATED,
requireUnlockedDevice = true
)
)
// Explicit plaintext write
ksafe.putDirect("theme", "dark", mode = KSafeWriteMode.Plain)No-mode writes (put/putDirect without mode) use encrypted defaults and pick up KSafeConfig.requireUnlockedDevice as the default unlock policy.
@Serializable
data class AuthInfo(
val accessToken: String = "",
val refreshToken: String = "",
val expiresIn: Long = 0L
)
var authInfo by ksafe(AuthInfo()) // encryption + JSON automatically
// Update
authInfo = authInfo.copy(accessToken = "newToken")Seeing "Serializer for class X' is not found"? Add
@Serializableand make sure you have added the Serialization plugin to your app.
KSafe fully supports nullable types:
// Store null values
val token: String? = null
ksafe.put("auth_token", token)
// Retrieve null values (returns null, not defaultValue)
val retrieved: String? = ksafe.get("auth_token", "default")
// retrieved == null ✓
// Nullable fields in serializable classes
@Serializable
data class UserProfile(
val id: Int,
val nickname: String?,
val bio: String?
)ksafe.delete("profile") // suspend (non-blocking)
ksafe.deleteDirect("profile") // blockingWhen you delete a value, both the data and its associated encryption key are removed from secure storage.
class CounterViewModel(ksafe: KSafe) : ViewModel() {
// regular Compose state (not persisted)
var volatile by mutableStateOf(0)
private set
// persisted Compose state (AES encrypted)
var persisted by ksafe.mutableStateOf(100)
private set
// plain property-delegate preference
var hits by ksafe(0)
fun inc() {
volatile++
persisted++
hits++
}
}By default, KSafe handles primitives, @Serializable data classes, lists, and nullable types automatically. But if you need to store third-party types you don't own (e.g., UUID, Instant, BigDecimal), you can inject a custom Json instance via KSafeConfig.
Types like java.util.UUID or kotlinx.datetime.Instant live in external libraries — you can't add @Serializable to them. Instead, you write a small KSerializer that teaches kotlinx.serialization how to convert the type to/from a string, then register it once when creating KSafe.
1. Define custom serializers for types you don't own
object UUIDSerializer : KSerializer<UUID> {
override val descriptor = PrimitiveSerialDescriptor("UUID", PrimitiveKind.STRING)
override fun serialize(encoder: Encoder, value: UUID) = encoder.encodeString(value.toString())
override fun deserialize(decoder: Decoder): UUID = UUID.fromString(decoder.decodeString())
}
object InstantSerializer : KSerializer<Instant> {
override val descriptor = PrimitiveSerialDescriptor("Instant", PrimitiveKind.STRING)
override fun serialize(encoder: Encoder, value: Instant) = encoder.encodeString(value.toString())
override fun deserialize(decoder: Decoder): Instant = Instant.parse(decoder.decodeString())
}2. Build a Json instance and register all your serializers in one place
val customJson = Json {
ignoreUnknownKeys = true
serializersModule = SerializersModule {
contextual(UUIDSerializer)
contextual(InstantSerializer)
// add as many as you need
}
}3. Pass it via KSafeConfig — one setup, used everywhere
val ksafe = KSafe(
context = context, // Android; omit on JVM/iOS/WASM
config = KSafeConfig(json = customJson)
)4. Use @Contextual types directly — no extra work at the call site
@Serializable
data class UserProfile(
val name: String,
@Contextual val id: UUID,
@Contextual val createdAt: Instant
)
// Works with all KSafe APIs
ksafe.putDirect("profile", UserProfile("Alice", UUID.randomUUID(), Instant.now()))
val profile: UserProfile = ksafe.getDirect("profile", defaultProfile)
// Suspend API
ksafe.put("profile", profile)
val loaded: UserProfile = ksafe.get("profile", defaultProfile)
// Flow
val profileFlow: Flow<UserProfile> = ksafe.getFlow("profile", defaultProfile)
// Property delegate
var saved: UserProfile by ksafe(defaultProfile, "profile", KSafeWriteMode.Plain)Note: If you don't need custom serializers, you don't need to configure anything — the default
Json { ignoreUnknownKeys = true }is used automatically viaKSafeDefaults.json.
Warning: Changing the
Jsonconfiguration for an existingfileNamenamespace may make previously stored non-primitive values unreadable. Primitives (String,Int,Boolean, etc.) are unaffected.
KSafeWriteMode and encrypted tiers via KSafeEncryptedProtection
var launchCount by ksafe(0) —that is literally it| Feature | SharedPrefs | DataStore | multiplatform-settings | KVault | KSafe |
|---|---|---|---|---|---|
| Thread safety | ❌ ANRs possible | ✅ Coroutine-safe | ✅ Platform-native | ✅ Thread-safe | ✅ ConcurrentHashMap + coroutines |
| Type safety | ❌ Runtime crashes | ✅ Compile-time | ✅ Generic API | ✅ Generic API | ✅ Reified generics + serialization |
| Data corruption | ❌ Crash = data loss | ✅ Atomic | ❌ Platform-dependent | ✅ Atomic | ✅ Uses DataStore atomicity |
| API style | ❌ Callbacks | ✅ Flow | ✅ Sync | ✅ Sync | ✅ Both sync & async |
| Encryption | ❌ None | ❌ None | ❌ None | ✅ Hardware-backed | ✅ Hardware-backed |
| Cross-platform | ❌ Android only | ❌ Android only | ✅ KMP | ✅ KMP | ✅ Android/iOS/JVM/WASM |
| Nullable support | ❌ No | ❌ No | ✅ Yes | ✅ Yes | ✅ Full support |
| Complex types | ❌ Manual | ❌ Manual/Proto | ❌ Manual | ❌ Manual | ✅ Auto-serialization |
| Biometric auth | ❌ Manual | ❌ Manual | ❌ Manual | ❌ Manual | ✅ Built-in |
| Memory policy | N/A | N/A | N/A | N/A | ✅ 3 policies (PLAIN_TEXT / ENCRYPTED / TIMED_CACHE) |
| Hot cache | ✅ Yes | ❌ No | ✅ Yes | ❌ No | ✅ ConcurrentHashMap |
| Write batching | ❌ No | ❌ No | ❌ No | ❌ No | ✅ 16ms coalescing |
Here are benchmark results comparing KSafe against popular Android persistence libraries.
| Library | Read | Write |
|---|---|---|
| SharedPreferences | 0.0017 ms | 0.0224 ms |
| MMKV | 0.0024 ms | 0.0232 ms |
| Multiplatform Settings | 0.0054 ms | 0.0228 ms |
| KSafe (Delegated) | 0.0073 ms | 0.0218 ms |
| DataStore | 0.5549 ms | 5.17 ms |
Note: KSafe unencrypted writes are on par with SharedPreferences (0.0218 ms vs 0.0224 ms) while providing KMP support, type-safe serialization, and optional encryption.
| Library | Time | vs KSafe |
|---|---|---|
| KSafe (PLAIN_TEXT memory) | 0.0174 ms | — |
| KVault | 0.2418 ms | KSafe is 14x faster |
| EncryptedSharedPreferences | 0.2603 ms | KSafe is 15x faster |
| KSafe (ENCRYPTED memory) | 4.93 ms | (real AES-GCM decryption via Keystore on every read) |
Note on ENCRYPTED memory policy: The ENCRYPTED memory policy keeps ciphertext in RAM and performs real AES-GCM decryption through the Android Keystore on every read (~5 ms). This is the cost of hardware-backed cryptography. For most use cases, use
PLAIN_TEXT(decrypts once at init) orENCRYPTED_WITH_TIMED_CACHE(decrypts once per TTL window).
| Library | Time | vs KSafe |
|---|---|---|
| KSafe (PLAIN_TEXT memory) | 0.0254 ms | — |
| KSafe (ENCRYPTED memory) | 0.0347 ms | — |
| EncryptedSharedPreferences | 0.2234 ms | KSafe is 9x faster |
| KVault | 0.8516 ms | KSafe is 34x faster |
vs DataStore (KSafe's backend):
vs KVault (encrypted KMP storage):
vs EncryptedSharedPreferences:
vs SharedPreferences (unencrypted baseline):
vs multiplatform-settings (Russell Wolf):
How fast can each library load existing data on app startup?
| Library | Keys | Time |
|---|---|---|
| SharedPreferences | 501 | 0.032 ms |
| Multiplatform Settings | 501 | 0.109 ms |
| MMKV | 501 | 0.119 ms |
| DataStore | 501 | 0.559 ms |
| KSafe (ENCRYPTED) | 1503 | 18.2 ms |
| KSafe (PLAIN_TEXT) | 3006 | 45.7 ms |
| EncryptedSharedPrefs | 501 | 56.2 ms |
| KVault | 650 | 58.3 ms |
Note: KSafe ENCRYPTED mode is 2.5x faster to cold-start than PLAIN_TEXT mode. This is because ENCRYPTED defers decryption until values are accessed, while PLAIN_TEXT decrypts all values upfront during initialization. Both KSafe modes cold-start faster than EncryptedSharedPreferences and KVault.
KSafe uses a hot cache architecture similar to SharedPreferences, but built on top of DataStore:
Vanilla DataStore:
Read: suspend → Flow.first() → disk I/O → ~0.55 ms
Write: suspend → edit{} → serialize → disk I/O → ~5.2 ms
KSafe with Hot Cache:
Read: getDirect() → ConcurrentHashMap lookup → ~0.007 ms (no disk!)
Write: putDirect() → update HashMap + queue → ~0.022 ms (returns immediately)
Background: batched DataStore.edit() (user doesn't wait)
Key optimizations:
This means KSafe gives you DataStore's safety guarantees (atomic transactions, type-safe) with SharedPreferences-level performance.
| Platform | Minimum Version | Notes |
|---|---|---|
| Android | API 23 (Android 6.0) | Hardware-backed Keystore on supported devices |
| iOS | iOS 13+ | Keychain-backed symmetric keys (protected by device passcode) |
| JVM/Desktop | JDK 11+ | Software-backed encryption |
| WASM/Browser | Any modern browser | WebCrypto API + localStorage |
| Dependency | Tested Version |
|---|---|
| Kotlin | 2.0.0+ |
| Kotlin Coroutines | 1.8.0+ |
| DataStore Preferences | 1.1.0+ |
| Compose Multiplatform | 1.6.0+ (for ksafe-compose) |
You can create multiple KSafe instances with different file names to separate different types of data:
class MyViewModel : ViewModel() {
private val userPrefs = KSafe(fileName = "userpreferences")
private val appSettings = KSafe(fileName = "appsettings")
private val cacheData = KSafe(fileName = "cache")
// For named instances, use suspend or direct APIs:
suspend fun saveUserToken(token: String) {
userPrefs.put("auth_token", token)
}
}Important Instance Management Rules:
// ✅ Good: Singleton instances via DI
val appModule = module {
single { KSafe() } // Default instance
single(named("user")) { KSafe(fileName = "userdata") }
single(named("cache")) { KSafe(fileName = "cache") }
}
// ❌ Bad: Creating multiple instances for the same file
class ScreenA { val prefs = KSafe(fileName = "userdata") }
class ScreenB { val prefs = KSafe(fileName = "userdata") } // DON'T DO THIS!File Name Requirements:
"userdata", "settings", "cache"
KSafe provides a standalone biometric authentication helper that works on both Android and iOS. This is a general-purpose utility that can protect any action in your app—not just KSafe persistence operations.
| Method | Type | Use Case |
|---|---|---|
verifyBiometricDirect(reason, authorizationDuration?) { success -> } |
Callback-based | Simple, non-blocking, works anywhere |
verifyBiometric(reason, authorizationDuration?): Boolean |
Suspend function | Coroutine-based, cleaner async code |
class MyViewModel(private val ksafe: KSafe) : ViewModel() {
var secureCounter by ksafe.mutableStateOf(0)
private set
// Always prompt (no caching)
fun incrementWithBiometric() {
ksafe.verifyBiometricDirect("Authenticate to increment") { success ->
if (success) secureCounter++
}
}
// Coroutine-based approach
fun incrementWithBiometricSuspend() {
viewModelScope.launch {
if (ksafe.verifyBiometric("Authenticate to increment")) {
secureCounter++
}
}
}
}Avoid repeated biometric prompts by caching successful authentication:
data class BiometricAuthorizationDuration(
val duration: Long, // Duration in milliseconds
val scope: String? = null // Optional scope identifier (null = global)
)
// Cache for 60 seconds (scoped to this ViewModel)
ksafe.verifyBiometricDirect(
reason = "Authenticate",
authorizationDuration = BiometricAuthorizationDuration(
duration = 60_000L,
scope = viewModelScope.hashCode().toString()
)
) { success -> /* ... */ }| Parameter | Meaning |
|---|---|
authorizationDuration = null |
Always prompt (no caching) |
duration > 0 |
Cache auth for this many milliseconds |
scope = null |
Global scope - any call benefits from cached auth |
scope = "xyz" |
Scoped auth - only calls with same scope benefit |
// ViewModel-scoped: auth invalidates when ViewModel is recreated
BiometricAuthorizationDuration(60_000L, viewModelScope.hashCode().toString())
// User-scoped: auth invalidates on user change
BiometricAuthorizationDuration(300_000L, "user_$userId")
// Flow-scoped: auth shared across a multi-step flow
BiometricAuthorizationDuration(120_000L, "checkout_flow")ksafe.clearBiometricAuth() // Clear all cached authorizations
ksafe.clearBiometricAuth("settings") // Clear specific scope only// Protect API calls
fun deleteAccount() {
ksafe.verifyBiometricDirect("Confirm account deletion") { success ->
if (success) api.deleteAccount()
}
}
// Protect navigation
fun navigateToSecrets() {
ksafe.verifyBiometricDirect("Authenticate to view secrets") { success ->
if (success) navController.navigate("secrets")
}
}Permission - Add to AndroidManifest.xml:
<uses-permission android:name="android.permission.USE_BIOMETRIC" />Activity Requirement - BiometricPrompt requires FragmentActivity or AppCompatActivity:
// ❌ Won't work with biometrics
class MainActivity : ComponentActivity()
// ✅ Works with biometrics
class MainActivity : AppCompatActivity()Early Initialization - KSafe must be initialized before any Activity is created:
class MyApplication : Application() {
override fun onCreate() {
super.onCreate()
startKoin {
androidContext(this@MyApplication)
modules(appModule)
}
get<KSafe>() // Force initialization
}
}Customizing the Prompt:
BiometricHelper.promptTitle = "Unlock Secure Data"
BiometricHelper.promptSubtitle = "Authenticate to continue"Info.plist - Add Face ID usage description:
<key>NSFaceIDUsageDescription</key>
<string>Authenticate to access secure data</string>Note: On iOS Simulator, biometric verification always returns true since there's no biometric hardware.
class SecureViewModel(private val ksafe: KSafe) : ViewModel() {
// Regular persisted counter (no biometric)
var counter by ksafe.mutableStateOf(0)
private set
// Counter that requires biometric to increment
var bioCounter by ksafe.mutableStateOf(0)
private set
fun incrementCounter() {
counter++ // No biometric prompt
}
// Always prompt
fun incrementBioCounter() {
ksafe.verifyBiometricDirect("Authenticate to save") { success ->
if (success) {
bioCounter++
}
}
}
// With 60s duration caching (scoped to this ViewModel instance)
fun incrementBioCounterCached() {
ksafe.verifyBiometricDirect(
reason = "Authenticate to save",
authorizationDuration = BiometricAuthorizationDuration(
duration = 60_000L,
scope = viewModelScope.hashCode().toString()
)
) { success ->
if (success) {
bioCounter++
}
}
}
// Suspend function with caching
fun incrementBioCounterAsync() {
viewModelScope.launch {
val authDuration = BiometricAuthorizationDuration(
duration = 60_000L,
scope = viewModelScope.hashCode().toString()
)
if (ksafe.verifyBiometric("Authenticate to save", authDuration)) {
bioCounter++
}
}
}
// Call on logout to force re-authentication
fun onLogout() {
ksafe.clearBiometricAuth() // Clear all cached auth
}
}Key Points:
verifyBiometricDirect) and suspend (verifyBiometric)BiometricAuthorizationDuration
AppCompatActivity and early KSafe initializationKSafe can detect and respond to runtime security threats:
val ksafe = KSafe(
context = context,
securityPolicy = KSafeSecurityPolicy(
rootedDevice = SecurityAction.WARN, // IGNORE, WARN, or BLOCK
debuggerAttached = SecurityAction.BLOCK,
debugBuild = SecurityAction.WARN,
emulator = SecurityAction.IGNORE,
onViolation = { violation ->
analytics.log("Security: ${violation.name}")
}
)
)| Check | Android | iOS | JVM | WASM | Description |
|---|---|---|---|---|---|
rootedDevice |
✅ | ✅ | ❌ | ❌ | Detects rooted/jailbroken devices |
debuggerAttached |
✅ | ✅ | ✅ | ❌ | Detects attached debuggers |
debugBuild |
✅ | ✅ | ✅ | ❌ | Detects debug builds |
emulator |
✅ | ✅ | ❌ | ❌ | Detects emulators/simulators |
| Action | Behavior | Use Case |
|---|---|---|
IGNORE |
No detection performed | Development, non-sensitive apps |
WARN |
Callback invoked, app continues | Logging/analytics, user warnings |
BLOCK |
Callback invoked, throws SecurityViolationException
|
Banking, enterprise apps |
Example behavior with WARN:
val ksafe = KSafe(
context = context,
securityPolicy = KSafeSecurityPolicy(
rootedDevice = SecurityAction.WARN,
onViolation = { violation ->
// This is called, but app continues working
showWarningDialog("Security risk: ${violation.name}")
analytics.log("security_warning", violation.name)
}
)
)
// KSafe initializes successfully, user sees warningExample behavior with BLOCK:
val ksafe = KSafe(
context = context,
securityPolicy = KSafeSecurityPolicy(
rootedDevice = SecurityAction.BLOCK,
onViolation = { violation ->
// This is called BEFORE the exception is thrown
analytics.log("security_block", violation.name)
}
)
)
// If device is rooted: SecurityViolationException is thrown
// App must catch this or it will crashKSafeSecurityPolicy.Default // All checks ignored (backwards compatible)
KSafeSecurityPolicy.Strict // Blocks on root/debugger, warns on debug/emulator
KSafeSecurityPolicy.WarnOnly // Warns on everything, never blockstry {
val ksafe = KSafe(context, securityPolicy = KSafeSecurityPolicy.Strict)
} catch (e: SecurityViolationException) {
showSecurityErrorScreen(e.violation.name)
}Since SecurityViolation is an enum without hardcoded messages, provide your own descriptions:
fun getViolationDescription(violation: SecurityViolation): String {
return when (violation) {
SecurityViolation.RootedDevice ->
"The device is rooted (Android) or jailbroken (iOS). " +
"This allows apps to bypass sandboxing and potentially access encrypted data."
SecurityViolation.DebuggerAttached ->
"A debugger is attached to the process. " +
"This allows inspection of memory and runtime values including decrypted secrets."
SecurityViolation.DebugBuild ->
"The app is running in debug mode. " +
"Debug builds may have weaker security settings and expose more information."
SecurityViolation.Emulator ->
"The app is running on an emulator/simulator. " +
"Emulators don't have hardware-backed security like real devices."
}
}Since KSafe initializes before ViewModels, use a holder to bridge violations to your UI:
// 1. Create a holder to collect violations during initialization
object SecurityViolationsHolder {
private val _violations = mutableListOf<SecurityViolation>()
val violations: List<SecurityViolation> get() = _violations.toList()
fun addViolation(violation: SecurityViolation) {
if (violation !in _violations) {
_violations.add(violation)
}
}
}
// 2. Configure KSafe to populate the holder
val ksafe = KSafe(
context = context,
securityPolicy = KSafeSecurityPolicy.Strict.copy(
onViolation = { violation ->
SecurityViolationsHolder.addViolation(violation)
}
)
)
// 3. Read from the holder in your ViewModel
class SecurityViewModel : ViewModel() {
val violations = mutableStateListOf<UiSecurityViolation>()
init {
SecurityViolationsHolder.violations.forEach { violation ->
violations.add(UiSecurityViolation(violation))
}
}
}When using SecurityViolation in Jetpack Compose, the Compose compiler treats it as "unstable" because it resides in the core ksafe module. The ksafe-compose module provides UiSecurityViolation—a wrapper marked with @Immutable:
@Immutable
data class UiSecurityViolation(
val violation: SecurityViolation
)| Type | Compose Stability |
|---|---|
ImmutableList<SecurityViolation> |
Unstable (causes recomposition) |
ImmutableList<UiSecurityViolation> |
Stable (enables skipping) |
The KSafeDemo app makes use of UiSecurityViolation—visit the demo application's source to see it in action.
su binary paths (/system/bin/su, /system/xbin/su, etc.)/sbin/.magisk, /data/adb/magisk, etc.)test-keys) and dangerous system properties/bin/bash, /usr/sbin/sshd, etc.)Limitation: Sophisticated root-hiding tools (Magisk DenyList, Shamiko, Zygisk) can bypass most client-side detection methods.
KSafe provides enterprise-grade encrypted persistence using DataStore Preferences with platform-specific secure key storage.
| Platform | Cipher | Key Storage | Security |
|---|---|---|---|
| Android | AES-256-GCM | Android Keystore — TEE by default, StrongBox opt-in | Keys non-exportable, app-bound, auto-deleted on uninstall |
| iOS | AES-256-GCM via CryptoKit | iOS Keychain Services — Secure Enclave opt-in | Protected by device passcode/biometrics, not in backups |
| JVM/Desktop | AES-256-GCM via javax.crypto | Software-backed in ~/.eu_anifantakis_ksafe/
|
Relies on OS file permissions (0700 on POSIX) |
| WASM/Browser | AES-256-GCM via WebCrypto |
localStorage (Base64-encoded) |
Scoped per origin, ~5-10 MB limit |
__ksafe_value_<key>__ksafe_meta_<key>__ as compact JSON{"v":1,"p":"DEFAULT"} or {"v":1,"p":"DEFAULT","u":"unlocked"})What is GCM? GCM (Galois/Counter Mode) is an authenticated encryption mode that provides both confidentiality and integrity. The authentication tag detects any tampering—if someone modifies even a single bit of the ciphertext, decryption will fail.
What KSafe protects against:
What KSafe does NOT protect against:
ENCRYPTED or ENCRYPTED_WITH_TIMED_CACHE memory policy)Recommendations:
KSafeSecurityPolicy.Strict for high-security apps (Banking, Medical, Enterprise)KSafeMemoryPolicy.ENCRYPTED for highly sensitive data (tokens, passwords)KSafeMemoryPolicy.ENCRYPTED_WITH_TIMED_CACHE for encrypted data accessed frequently during UI rendering (Compose recomposition, SwiftUI re-render)A note on hardware security models: By default, Android stores AES keys in the TEE (Trusted Execution Environment) — a hardware-isolated zone on the main processor where encryption happens entirely on-chip and the key never enters app memory. With mode = KSafeWriteMode.Encrypted(KSafeEncryptedProtection.HARDWARE_ISOLATED), KSafe targets a physically separate security chip (StrongBox on Android, Secure Enclave on iOS) with automatic fallback to default hardware. On iOS, HARDWARE_ISOLATED uses envelope encryption: an EC P-256 key pair in the Secure Enclave wraps/unwraps the AES symmetric key via ECIES, so the AES key material is hardware-protected even though AES-GCM itself runs in CryptoKit. Without hardware isolation, AES keys are stored as Keychain items — still encrypted by the OS and protected by the device passcode.
Hardware isolation (per-property):
// StrongBox on Android, Secure Enclave on iOS
var secret by ksafe(
"",
mode = KSafeWriteMode.Encrypted(KSafeEncryptedProtection.HARDWARE_ISOLATED)
)
// Or with suspend/direct API
ksafe.put("secret", value, mode = KSafeWriteMode.Encrypted(KSafeEncryptedProtection.HARDWARE_ISOLATED))
ksafe.putDirect("secret", value, mode = KSafeWriteMode.Encrypted(KSafeEncryptedProtection.HARDWARE_ISOLATED))Hardware isolation provides the highest security level — keys live on a dedicated chip that is physically separate from the main processor. If the device lacks the hardware, KSafe automatically falls back to the platform default with no code changes required. Note that hardware-isolated key generation is slower and per-operation latency is higher, so only enable it for high-security use cases. KSafe's memory policies mitigate read-side latency since most reads come from the hot cache.
Migrating existing keys to hardware isolation: Using HARDWARE_ISOLATED only affects new key generation. Existing keys continue working from wherever they were originally generated. To migrate existing data to hardware-isolated keys, delete the KSafe data (or the specific keys) and reinitialize.
Per-key metadata (single entry): Each key stores one metadata entry (__ksafe_meta_{key}__) that includes:
p → protection tier (NONE, DEFAULT, HARDWARE_ISOLATED)u → unlock policy ("unlocked" when requireUnlockedDevice=true)This metadata is used for read auto-detection and getKeyInfo().
Legacy metadata (__ksafe_prot_{key}__) is still read for backward compatibility and cleaned on next write/delete.
KSafe exposes properties and methods to query what security hardware is available on the device, and to inspect both the protection tier (what the caller requested) and storage location (where the key material actually lives) of individual keys:
val ksafe = KSafe(context)
// Device-level: what hardware is available?
ksafe.deviceKeyStorages // e.g. {HARDWARE_BACKED, HARDWARE_ISOLATED}
ksafe.deviceKeyStorages.max() // HARDWARE_ISOLATED (highest available)
// Per-key: what protection was used and where is the key stored?
val info = ksafe.getKeyInfo("auth_token")
// info?.protection → KSafeProtection.DEFAULT (encrypted tier, null if plaintext)
// info?.storage → KSafeKeyStorage.HARDWARE_BACKED (where the key lives)getKeyInfo returns a KSafeKeyInfo data class:
data class KSafeKeyInfo(
val protection: KSafeProtection?, // null, DEFAULT, or HARDWARE_ISOLATED
val storage: KSafeKeyStorage // SOFTWARE, HARDWARE_BACKED, or HARDWARE_ISOLATED
)The KSafeKeyStorage enum has three levels with natural ordinal ordering:
| Level | Meaning | Platforms |
|---|---|---|
SOFTWARE |
Software-only (file system / localStorage) | JVM, WASM |
HARDWARE_BACKED |
On-chip hardware (TEE / Keychain) | Android, iOS |
HARDWARE_ISOLATED |
Dedicated security chip (StrongBox / Secure Enclave) | Android (if available), iOS (real devices) |
Query what hardware security levels the device supports:
| Platform | deviceKeyStorages |
|---|---|
| Android | Always {HARDWARE_BACKED}. Adds HARDWARE_ISOLATED if PackageManager.FEATURE_STRONGBOX_KEYSTORE is present (API 28+). |
| iOS | Always {HARDWARE_BACKED}. Adds HARDWARE_ISOLATED on real devices (not simulator). |
| JVM | {SOFTWARE} |
| WASM | {SOFTWARE} |
Query the protection tier and storage location of a specific key:
ksafe.getKeyInfo("auth_token") // KSafeKeyInfo(DEFAULT, HARDWARE_BACKED) on Android/iOS
ksafe.getKeyInfo("theme") // KSafeKeyInfo(null, SOFTWARE) if unencrypted
ksafe.getKeyInfo("nonexistent") // null (key doesn't exist)| Scenario | Return value |
|---|---|
| Key not found | null |
| Unencrypted key | KSafeKeyInfo(null, SOFTWARE) |
| Encrypted key (Android/iOS) | KSafeKeyInfo(DEFAULT, HARDWARE_BACKED) |
| Encrypted key (JVM/WASM) | KSafeKeyInfo(DEFAULT, SOFTWARE) |
HARDWARE_ISOLATED key (device supports it) |
KSafeKeyInfo(HARDWARE_ISOLATED, HARDWARE_ISOLATED) |
HARDWARE_ISOLATED key (device lacks it, fell back) |
KSafeKeyInfo(HARDWARE_ISOLATED, HARDWARE_BACKED) |
Use cases:
KSafeEncryptedProtection level based on what the device supports// Adaptive protection based on device capabilities
val protection = if (KSafeKeyStorage.HARDWARE_ISOLATED in ksafe.deviceKeyStorages)
KSafeEncryptedProtection.HARDWARE_ISOLATED
else
KSafeEncryptedProtection.DEFAULT
var secret by ksafe("", mode = KSafeWriteMode.Encrypted(protection))
// Verify a key's actual storage level after writing
ksafe.putDirect("secret", "value", mode = KSafeWriteMode.Encrypted(KSafeEncryptedProtection.HARDWARE_ISOLATED))
val info = ksafe.getKeyInfo("secret")
// info?.protection == HARDWARE_ISOLATED (always matches what was requested)
// info?.storage == HARDWARE_ISOLATED on devices with StrongBox/SE, HARDWARE_BACKED otherwiseKSafe now uses canonical, namespaced storage keys:
__ksafe_value_{key}
__ksafe_meta_{key}__
Legacy keys are still supported on reads:
encrypted_{key}{key}
__ksafe_prot_{key}__Migration is lazy and safe:
put/putDirect) always persist canonical keys and remove legacy entries for that key.Control the trade-off between performance and security for data in RAM:
val ksafe = KSafe(
fileName = "secrets",
memoryPolicy = KSafeMemoryPolicy.ENCRYPTED // Default
)| Policy | Best For | RAM Contents | Read Cost | Security |
|---|---|---|---|---|
PLAIN_TEXT |
User settings, themes | Plaintext (forever) | O(1) lookup | Low — all data exposed in memory |
ENCRYPTED (Default) |
Tokens, passwords | Ciphertext only | AES-GCM decrypt every read | High — nothing plaintext in RAM |
ENCRYPTED_WITH_TIMED_CACHE |
Compose/SwiftUI screens | Ciphertext + short-lived plaintext | First read decrypts, then O(1) for TTL | Medium — plaintext only for recently-accessed keys, only for seconds |
All three policies encrypt data on disk. The difference is how data is handled in memory:
Under ENCRYPTED policy, every read triggers AES-GCM decryption. In UI frameworks like Jetpack Compose or SwiftUI, the same encrypted property may be read multiple times during a single recomposition/re-render. ENCRYPTED_WITH_TIMED_CACHE eliminates redundant crypto: only the first read decrypts; subsequent reads within the TTL window are pure memory lookups.
val ksafe = KSafe(
context = context,
memoryPolicy = KSafeMemoryPolicy.ENCRYPTED_WITH_TIMED_CACHE,
plaintextCacheTtl = 5.seconds // default; how long plaintext stays cached
)How it works internally:
Read 1: decrypt → cache plaintext (TTL=5s) → return ← one crypto operation
Read 2 (50ms later): cache hit → return ← no decryption
Read 3 (100ms later): cache hit → return ← no decryption
...TTL expires...
Read 4: decrypt → cache plaintext (TTL=5s) → return ← one crypto operation
Thread safety: Reads capture a local reference to the cached entry atomically. No background sweeper — expired entries are simply ignored on the next access. No race conditions possible.
val archive = KSafe(
fileName = "archive",
lazyLoad = true // Skip preload, load on first request
)// Android
KSafe(
context: Context,
fileName: String? = null,
lazyLoad: Boolean = false,
memoryPolicy: KSafeMemoryPolicy = KSafeMemoryPolicy.ENCRYPTED,
config: KSafeConfig = KSafeConfig(),
securityPolicy: KSafeSecurityPolicy = KSafeSecurityPolicy.Default,
plaintextCacheTtl: Duration = 5.seconds // only used with ENCRYPTED_WITH_TIMED_CACHE
)
// iOS / JVM / WASM
KSafe(
fileName: String? = null,
lazyLoad: Boolean = false,
memoryPolicy: KSafeMemoryPolicy = KSafeMemoryPolicy.ENCRYPTED,
config: KSafeConfig = KSafeConfig(),
securityPolicy: KSafeSecurityPolicy = KSafeSecurityPolicy.Default,
plaintextCacheTtl: Duration = 5.seconds // only used with ENCRYPTED_WITH_TIMED_CACHE
)val ksafe = KSafe(
context = context,
config = KSafeConfig(
keySize = 256, // AES key size: 128 or 256 bits
requireUnlockedDevice = false // Default for protection-based encrypted writes
)
)Note: The encryption algorithm (AES-GCM) is intentionally NOT configurable to prevent insecure configurations.
Control whether encrypted data is only accessible when the device is unlocked.
You now have two options:
KSafeWriteMode.Encrypted(requireUnlockedDevice = ...)
KSafeConfig(requireUnlockedDevice = ...) for no-mode encrypted writes (put/putDirect without mode)// Per-entry policy (recommended)
ksafe.put(
"auth_token",
token,
mode = KSafeWriteMode.Encrypted(
protection = KSafeEncryptedProtection.DEFAULT,
requireUnlockedDevice = true
)
)
// Fallback default for no-mode encrypted writes
val ksafe = KSafe(
context = context,
config = KSafeConfig(requireUnlockedDevice = true)
)
| Platform |
false (default) |
true |
|---|---|---|
| Android | Keys accessible at any time |
setUnlockedDeviceRequired(true) (API 28+) |
| iOS | AfterFirstUnlockThisDeviceOnly |
WhenUnlockedThisDeviceOnly |
| JVM | No effect (software keys) | No effect (software keys) |
| WASM | No effect (browser has no lock concept) | No effect |
Important: requireUnlockedDevice applies only to encrypted writes.
KSafeWriteMode.Plain intentionally does not use unlock policy.
Metadata shape: unlock policy is recorded per key in __ksafe_meta_{key}__ JSON ("u":"unlocked" only when enabled). There is no global per-instance access-policy marker.
Error behavior when locked: When requireUnlockedDevice = true and the device is locked, encrypted reads (getDirect, get, getFlow) throw IllegalStateException. The suspend put() also throws for encrypted data. However, putDirect does not throw to the caller — it queues the write to a background consumer that logs the error and drops the batch (the consumer stays alive for future writes after the device is unlocked). Your app can catch read-side exceptions to show a "device is locked" message instead of silently receiving default values.
You can still use multiple instances for hard separation (for example, secure and prefs), but it is no longer required for lock-policy control because policy can be set per write entry.
// Android example with Koin
actual val platformModule = module {
// Sensitive data: only accessible when device is unlocked
single(named("secure")) {
KSafe(
context = androidApplication(),
fileName = "secure",
config = KSafeConfig(requireUnlockedDevice = true)
)
}
// General preferences: accessible even when locked (e.g., for background sync)
single(named("prefs")) {
KSafe(
context = androidApplication(),
fileName = "prefs",
config = KSafeConfig(requireUnlockedDevice = false)
)
}
}
// Usage in ViewModel
class MyViewModel(
private val secureSafe: KSafe, // tokens, passwords — locked when device is locked
private val prefsSafe: KSafe // settings, cache — always accessible
) : ViewModel() {
var authToken by secureSafe("")
var lastSyncTime by prefsSafe(0L)
}This pattern is especially useful for apps that perform background work (push notifications, sync) while the device is locked — the background-safe instance can still access its data, while the secure instance protects sensitive values.
KSafe 1.2.0 introduced a completely rewritten core architecture focusing on zero-latency UI performance.
Before (v1.1.x): Every getDirect() call triggered a blocking disk read and decryption on the calling thread.
Now (v1.2.0): Data is preloaded asynchronously on initialization. getDirect() performs an Atomic Memory Lookup (O(1)), returning instantly.
Safety: If data is accessed before the preload finishes, the library automatically falls back to a blocking read.
putDirect() updates the in-memory cache immediately, allowing your UI to reflect changes instantly while disk encryption happens in the background.
┌─────────────────────────────────────────────────────────────┐
│ KSafe API │
│ (get, put, getDirect, putDirect, delete) │
└─────────────────────────┬───────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ KSafeConfig │
│ (keySize) │
└─────────────────────────┬───────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ KSafeEncryption Interface │
│ encrypt() / decrypt() / deleteKey() │
└─────────────────────────┬───────────────────────────────────┘
│
┌───────────────┼───────────────┬───────────────┐
▼ ▼ ▼ ▼
┌─────────────────┐ ┌───────────────┐ ┌─────────────┐ ┌─────────────┐
│ Android │ │ iOS │ │ JVM │ │ WASM │
│ Keystore │ │ Keychain │ │ Software │ │ WebCrypto │
│ Encryption │ │ Encryption │ │ Encryption │ │ Encryption │
└─────────────────┘ └───────────────┘ └─────────────┘ └─────────────┘
KSafeEncryptedProtection.HARDWARE_ISOLATED (through KSafeWriteMode.Encrypted) — uses a physically separate security chip with automatic TEE fallback on devices without StrongBoxKSafeEncryptedProtection.HARDWARE_ISOLATED (through KSafeWriteMode.Encrypted) — uses envelope encryption (SE-backed EC P-256 wraps/unwraps the AES key) with automatic Keychain fallback on devices without SElocalStorage (Base64-encoded)PLAIN_TEXT internally (WebCrypto is async-only)KSafe's hardware-backed encryption has been tested and verified on real devices:
| Platform | Device | Hardware Security |
|---|---|---|
| iOS | iPhone 15 Pro Max (A17 Pro) | Secure Enclave |
| Android | Samsung Galaxy S24 Ultra (Snapdragon 8 Gen 3) | StrongBox (Knox Vault) |
If decryption fails (e.g., corrupted data or missing key), KSafe gracefully returns the default value, ensuring your app continues to function.
Exception: When requireUnlockedDevice = true and the device is locked, KSafe throws IllegalStateException instead of returning the default value. This allows your app to detect and handle the locked state explicitly (e.g., showing a "device is locked" message).
KSafe ensures clean reinstalls on all platforms:
Note on unencrypted values: The orphaned ciphertext cleanup targets only encrypted entries (those with the
encrypted_prefix in DataStore). Unencrypted values (encrypted = false) are not affected by this cleanup. On Android, ifandroid:allowBackup="true"is set in the manifest, Auto Backup may restore unencrypted DataStore entries after reinstall with stale values from the last backup snapshot.
On startup, KSafe probes each encrypted DataStore entry by attempting decryption:
requireUnlockedDevice setting (default: accessible after first unlock)setUnlockedDeviceRequired requires API 28+localStorage which can be cleared by the user. Security checks (root, debugger, emulator) are no-ops# Run all tests across all platforms
./gradlew allTests
# Run common tests only
./gradlew :ksafe:commonTest
# Run JVM tests
./gradlew :ksafe:jvmTest
# Run Android unit tests (Note: May fail in Robolectric due to KeyStore limitations)
./gradlew :ksafe:testDebugUnitTest
# Run Android instrumented tests on connected device/emulator (Recommended for Android)
./gradlew :ksafe:connectedDebugAndroidTest
# Run iOS tests on simulator
./gradlew :ksafe:iosSimulatorArm64Test
# Run a specific test class
./gradlew :ksafe:commonTest --tests "*.KSafeTest"Note: iOS Simulator uses real Keychain APIs (software-backed), while real devices store Keychain data in a hardware-encrypted container protected by the device passcode.
./gradlew :ksafe:linkDebugFrameworkIosSimulatorArm64 # For simulator
./gradlew :ksafe:linkDebugFrameworkIosArm64 # For physical devicecd iosTestApp
xcodebuild -scheme KSafeTestApp \
-configuration Debug \
-sdk iphonesimulator \
-arch arm64 \
-derivedDataPath build \
buildxcrun simctl list devices | grep "Booted"
xcrun simctl install DEVICE_ID build/Build/Products/Debug-iphonesimulator/KSafeTestApp.app
xcrun simctl launch DEVICE_ID com.example.KSafeTestAppcd iosTestApp
xcodebuild -scheme KSafeTestApp \
-configuration Debug \
-sdk iphoneos \
-derivedDataPath build \
buildImportant Notes:
The iOS test app demonstrates:
putDirect to immediately update valuesThe encrypted: Boolean parameter on all API methods is deprecated at DeprecationLevel.WARNING — code using it still compiles but shows strikethrough warnings in the IDE with one-click ReplaceWith auto-fix. Migrate to KSafeWriteMode:
// Old (WARNING — still compiles but deprecated)
ksafe.put("key", value, encrypted = true)
ksafe.get("key", "", encrypted = false)
// New — writes specify mode, reads auto-detect
ksafe.put("key", value) // encrypted default
ksafe.put("key", value, mode = KSafeWriteMode.Plain) // unencrypted
val v = ksafe.get("key", "") // auto-detectsThe mapping is: encrypted = true → KSafeWriteMode.Encrypted(), encrypted = false → KSafeWriteMode.Plain.
KSafe now writes:
__ksafe_value_{key}
__ksafe_meta_{key}__
Legacy keys (encrypted_{key}, bare {key}, __ksafe_prot_{key}__) are still readable and are cleaned when that key is next written/deleted.
Read methods (get, getDirect, getFlow, getStateFlow) no longer accept a protection parameter. They automatically detect whether stored data is encrypted from persisted metadata. You specify write behavior via mode:
// Writes — specify mode
ksafe.put("secret", token) // encrypted (default)
ksafe.putDirect("theme", "dark", mode = KSafeWriteMode.Plain) // unencrypted
var pin by ksafe(
"",
mode = KSafeWriteMode.Encrypted(KSafeEncryptedProtection.HARDWARE_ISOLATED)
) // StrongBox / SE
// Reads — auto-detect, no protection needed
val secret = ksafe.get("secret", "")
val theme = ksafe.getDirect("theme", "light")
val flow = ksafe.getFlow("secret", "")This eliminates the common mistake of mismatching protection levels between put and get calls.
The public API surface (get, put, getDirect, putDirect) remains backward compatible.
lazyLoad = true.null values.If upgrading from early 1.2.0 alphas, update your imports:
// Old (broken in alpha versions)
import eu.eu.anifantakis.lib.ksafe.compose.mutableStateOf
// New (correct)
import eu.anifantakis.lib.ksafe.compose.mutableStateOf| Feature | KSafe | EncryptedSharedPrefs | KVault | Multiplatform Settings | SQLCipher |
|---|---|---|---|---|---|
| KMP Support | ✅ Android, iOS, JVM, WASM | ❌ Android only | ✅ Android, iOS | ✅ Multi-platform | |
| Hardware-backed Keys | ✅ Keystore/Keychain | ✅ Keystore | ✅ Keystore/Keychain | ❌ No encryption | ❌ Software |
| Zero Boilerplate | ✅ by ksafe(0)
|
❌ Verbose API | ❌ SQL required | ||
| Biometric Helper | ✅ Built-in | ❌ Manual | ❌ Manual | ❌ Manual | ❌ Manual |
| Compose State | ✅ mutableStateOf
|
❌ Manual | ❌ Manual | ❌ Manual | ❌ Manual |
| Type Safety | ✅ Reified generics | ✅ Good | ✅ Good | ❌ SQL strings | |
| Auth Caching | ✅ Scoped sessions | ❌ No | ❌ No | ❌ No | ❌ No |
When to choose KSafe:
by ksafe(x)) for minimal boilerplateWhen to consider alternatives:
Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License.
You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License.
The Universal Persistence Layer: Effortless Enterprise-Grade Security AND Lightning-Fast Plain-Text Storage for Android, iOS, Desktop, and Web.
To see KSafe in action on several scenarios, I invite you to check out my demo application here: Demo CMP App Using KSafe
Check out my own video about how easy it is to adapt KSafe into your project and get seamless encrypted persistence, but also more videos from other content creators.
| Author's Video | Philipp Lackner's Video | Jimmy Plazas's Video |
|---|---|---|
| KSafe - Kotlin Multiplatform Encrypted DataStore Persistence Library | How to Encrypt Local Preferences In KMP With KSafe | Encripta datos localmente en Kotlin Multiplatform con KSafe - Ejemplo + Arquitectura |
library to persist encrypted and unencrypted data in Kotlin Multiplatform.
With simple property delegation, values feel like normal variables — you just read and write them, and KSafe handles the underlying cryptography, caching, and atomic DataStore persistence transparently across all four platforms: Android, iOS, JVM/Desktop, and WASM/JS (Browser).
Think KSafe is overkill for a simple "Dark Mode" toggle? Think again.
By setting mode = KSafeWriteMode.Plain, KSafe completely bypasses the cryptographic engine. What remains is a lightning-fast, zero-boilerplate wrapper around AndroidX DataStore with a concurrent hot-cache.
Setting up raw KMP DataStore requires writing expect/actual file paths across 4 platforms, managing CoroutineScopes, and dealing with async-only Flow reads. KSafe abstracts 100% of that. You get synchronous, O(1) reads backed by asynchronous disk writes—all in one line of code. Unencrypted KSafe writes are actually benchmarked to be faster than native Android SharedPreferences.
Whether you are storing a harmless UI state or a highly sensitive biometric token, KSafe is the only local persistence dependency your KMP app needs.
Here's what that looks like in a real app — Ktor bearer authentication with zero encryption boilerplate:
@Serializable
data class AuthTokens(
val accessToken: String = "",
val refreshToken: String = ""
)
// One line to encrypt, persist, and serialize the whole object
var tokens by ksafe(AuthTokens())
install(Auth) {
bearer {
loadTokens {
// Reads atomic object from hot cache (~0.007ms). No disk. No suspend.
BearerTokens(tokens.accessToken, tokens.refreshToken)
}
refreshTokens {
val newInfo = api.refreshAuth(tokens.refreshToken)
// Atomic update: encrypts & persists as JSON in background (~13μs)
tokens = AuthTokens(
accessToken = newInfo.accessToken,
refreshToken = newInfo.refreshToken
)
BearerTokens(tokens.accessToken, tokens.refreshToken)
}
}
}No explicit encrypt/decrypt calls. No DataStore boilerplate. No runBlocking. Tokens are AES-256-GCM encrypted at rest, served from the hot cache at runtime, and survive process death — all through regular Kotlin property access.
Under the hood, each platform uses its native crypto engine — Android Keystore, iOS Keychain + CryptoKit, JVM's javax.crypto, and browser WebCrypto — unified behind a single API. Values are AES-256-GCM encrypted and persisted to DataStore (or localStorage on WASM). Beyond property delegation, KSafe also offers Compose state integration (ksafe.mutableStateOf()), reactive flows (getFlow() / getStateFlow()), built-in biometric authentication, configurable memory policies, and runtime security detection (root/jailbreak, debugger, emulator) — all out of the box.
// 1. Create instance (Android needs context, others don't)
val ksafe = KSafe(context) // Android
val ksafe = KSafe() // iOS / JVM / WASM
// 2. Store & retrieve with property delegation
var counter by ksafe(0)
counter++ // Auto-encrypted, auto-persisted
// 3. Or use suspend API
viewModelScope.launch {
ksafe.put("user_token", token)
val token = ksafe.get("user_token", "")
}
// 4. Protect actions with biometrics
ksafe.verifyBiometricDirect("Confirm payment") { success ->
if (success) processPayment()
}That's it. Your data is now AES-256-GCM encrypted with keys stored in Android Keystore, iOS Keychain, software-backed on JVM, or WebCrypto on WASM.
// commonMain or Android-only build.gradle(.kts)
implementation("eu.anifantakis:ksafe:1.7.1")
implementation("eu.anifantakis:ksafe-compose:1.7.1") // ← Compose state (optional)Skip
ksafe-composeif your project doesn't use Jetpack Compose, or if you don't intend to use the library'smutableStateOfpersistence option
Note:
kotlinx-serialization-jsonis exposed as a transitive dependency — you do not need to add it manually to your project. KSafe already provides it.
If you want to use the library with data classes, you need to enable Serialization at your project.
Add Serialization definition to your plugins section of your libs.versions.toml
[versions]
kotlin = "2.2.21"
[plugins]
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }and apply it at the same section of your build.gradle.kts file.
plugins {
//...
alias(libs.plugins.kotlin.serialization)
}Koin is the defacto DI solution for Kotlin Multiplatform, and is the ideal tool to provide KSafe as a singleton.
Performance guidance — "prefs" vs "vault": Encryption adds overhead to every write (AES-GCM + Keystore/Keychain round-trip). For data that doesn't need confidentiality — theme preferences, last-visited screen, UI flags — use
mode = KSafeWriteMode.Plainto get SharedPreferences-level speed. Reserve encryption for secrets like tokens, passwords, and PII. The easiest way to enforce this is to create two named singletons:
// ──────────────────────────────────────────────
// common
// ──────────────────────────────────────────────
expect val platformModule: Module
// ──────────────────────────────────────────────
// Android
// ──────────────────────────────────────────────
actual val platformModule = module {
// Fast, unencrypted — for everyday preferences
single(named("prefs")) {
KSafe(
context = androidApplication(),
fileName = "prefs",
memoryPolicy = KSafeMemoryPolicy.PLAIN_TEXT
)
}
// Encrypted — for secrets (tokens, passwords, PII)
single(named("vault")) {
KSafe(
context = androidApplication(),
fileName = "vault"
)
}
}
// ──────────────────────────────────────────────
// iOS
// ──────────────────────────────────────────────
actual val platformModule = module {
single(named("prefs")) {
KSafe(
fileName = "prefs",
memoryPolicy = KSafeMemoryPolicy.PLAIN_TEXT
)
}
single(named("vault")) {
KSafe(
fileName = "vault"
)
}
}
// ──────────────────────────────────────────────
// JVM/Desktop
// ──────────────────────────────────────────────
actual val platformModule = module {
single(named("prefs")) {
KSafe(
fileName = "prefs",
memoryPolicy = KSafeMemoryPolicy.PLAIN_TEXT
)
}
single(named("vault")) {
KSafe(fileName = "vault")
}
}
// ──────────────────────────────────────────────
// WASM — call ksafe.awaitCacheReady() before first encrypted read (see note below)
// ──────────────────────────────────────────────
actual val platformModule = module {
single(named("prefs")) {
KSafe(
fileName = "prefs",
memoryPolicy = KSafeMemoryPolicy.PLAIN_TEXT
)
}
single(named("vault")) {
KSafe(fileName = "vault")
}
}Then inject by name in your ViewModels:
class MyViewModel(
private val prefs: KSafe, // @Named("prefs") — fast, unencrypted
private val vault: KSafe // @Named("vault") — encrypted secrets
) : ViewModel() {
// UI preferences — no encryption overhead
var theme by prefs("dark", mode = KSafeWriteMode.Plain)
var lastScreen by prefs("home", mode = KSafeWriteMode.Plain)
var onboarded by prefs(false, mode = KSafeWriteMode.Plain)
// Secrets — AES-256-GCM encrypted, hardware-backed keys
var authToken by vault("")
var refreshToken by vault("")
var userPin by vault(
"",
mode = KSafeWriteMode.Encrypted(KSafeEncryptedProtection.HARDWARE_ISOLATED)
) // StrongBox / SE
}Of course, if your app only stores secrets you can use a single default instance — the two-instance pattern is a recommendation for apps that mix everyday preferences with sensitive data.
// Single instance (perfectly fine if everything needs encryption)
// Android
actual val platformModule = module {
single { KSafe(androidApplication()) }
}
// iOS / JVM / WASM
actual val platformModule = module {
single { KSafe() }
}WASM/JS: WebCrypto encryption is async-only, so KSafe must finish decrypting its cache before your UI reads any encrypted values. Call
awaitCacheReady()before rendering content.With
startKoin(classic):fun main() { startKoin { modules(sharedModule, platformModule) } val body = document.body ?: return ComposeViewport(body) { var cacheReady by remember { mutableStateOf(false) } LaunchedEffect(Unit) { val ksafe: KSafe = getKoin().get() ksafe.awaitCacheReady() cacheReady = true } if (cacheReady) { App() } } }With
KoinMultiplatformApplication(Compose):fun main() { val body = document.body ?: return ComposeViewport(body) { KoinMultiplatformApplication(config = createKoinConfiguration()) { var cacheReady by remember { mutableStateOf(false) } LaunchedEffect(Unit) { val ksafe: KSafe = getKoin().get() ksafe.awaitCacheReady() cacheReady = true } if (cacheReady) { AppContent() // your app's UI (without KoinMultiplatformApplication wrapper) } } } }With
startKoin, Koin is initialized beforeComposeViewport, sogetKoin()works immediately. WithKoinMultiplatformApplication,awaitCacheReady()must go inside the composable — Koin isn't available until that scope.
Now you're ready to inject KSafe into your ViewModels!
var counter by ksafe(0)Parameters:
defaultValue - must be declared (type is inferred from it)key - if not set, the variable name is used as a keymode (overload) - KSafeWriteMode.Plain or KSafeWriteMode.Encrypted(...) for per-entry controlclass MyViewModel(ksafe: KSafe): ViewModel() {
var counter by ksafe(0)
init {
// then just use it as a regular variable
counter++
}
}Important: The property delegate can ONLY use the default KSafe instance. If you need to use multiple KSafe instances with different file names, you must use the suspend or direct APIs.
var counter by ksafe.mutableStateOf(0)Recomposition-proof and survives process death with zero boilerplate. Requires the ksafe-compose dependency.
class MyViewModel(ksafe: KSafe): ViewModel() {
var counter by ksafe.mutableStateOf(0)
private set
init {
counter++
}
}When you need custom Compose equality semantics, use the advanced overload with policy:
import androidx.compose.runtime.neverEqualPolicy
import androidx.compose.runtime.referentialEqualityPolicy
// Default (recommended): structural equality
var profile by ksafe.mutableStateOf(Profile())
// Persist/recompose only when reference changes
var uiModel by ksafe.mutableStateOf(
defaultValue = UiModel(),
policy = referentialEqualityPolicy()
)
// Always treat assignment as a change (always persists)
var ticks by ksafe.mutableStateOf(
defaultValue = 0,
policy = neverEqualPolicy()
)// inside coroutine / suspend fn
ksafe.put("profile", userProfile) // encrypt & persist
val cached: User = ksafe.get("profile", User())ksafe.putDirect("counter", 42)
val n = ksafe.getDirect("counter", 0)Performance Note: For bulk or concurrent operations, always use the Direct API. The Coroutine API waits for DataStore persistence on each call (~22 ms), while the Direct API returns immediately from the hot cache (~0.022 ms) — that's ~1000x faster.
| API | Read | Write | Best For |
|---|---|---|---|
getDirect/putDirect
|
0.007 ms | 0.022 ms | UI, bulk ops, high throughput |
get/put (suspend) |
0.010 ms | 22 ms | When you must guarantee persistence |
Use KSafeWriteMode when you need encrypted-only options like requireUnlockedDevice:
// Direct API
ksafe.putDirect(
"token",
token,
mode = KSafeWriteMode.Encrypted(
protection = KSafeEncryptedProtection.DEFAULT,
requireUnlockedDevice = true
)
)
// Suspend API
ksafe.put(
"pin",
pin,
mode = KSafeWriteMode.Encrypted(
protection = KSafeEncryptedProtection.HARDWARE_ISOLATED,
requireUnlockedDevice = true
)
)
// Explicit plaintext write
ksafe.putDirect("theme", "dark", mode = KSafeWriteMode.Plain)No-mode writes (put/putDirect without mode) use encrypted defaults and pick up KSafeConfig.requireUnlockedDevice as the default unlock policy.
@Serializable
data class AuthInfo(
val accessToken: String = "",
val refreshToken: String = "",
val expiresIn: Long = 0L
)
var authInfo by ksafe(AuthInfo()) // encryption + JSON automatically
// Update
authInfo = authInfo.copy(accessToken = "newToken")Seeing "Serializer for class X' is not found"? Add
@Serializableand make sure you have added the Serialization plugin to your app.
KSafe fully supports nullable types:
// Store null values
val token: String? = null
ksafe.put("auth_token", token)
// Retrieve null values (returns null, not defaultValue)
val retrieved: String? = ksafe.get("auth_token", "default")
// retrieved == null ✓
// Nullable fields in serializable classes
@Serializable
data class UserProfile(
val id: Int,
val nickname: String?,
val bio: String?
)ksafe.delete("profile") // suspend (non-blocking)
ksafe.deleteDirect("profile") // blockingWhen you delete a value, both the data and its associated encryption key are removed from secure storage.
class CounterViewModel(ksafe: KSafe) : ViewModel() {
// regular Compose state (not persisted)
var volatile by mutableStateOf(0)
private set
// persisted Compose state (AES encrypted)
var persisted by ksafe.mutableStateOf(100)
private set
// plain property-delegate preference
var hits by ksafe(0)
fun inc() {
volatile++
persisted++
hits++
}
}By default, KSafe handles primitives, @Serializable data classes, lists, and nullable types automatically. But if you need to store third-party types you don't own (e.g., UUID, Instant, BigDecimal), you can inject a custom Json instance via KSafeConfig.
Types like java.util.UUID or kotlinx.datetime.Instant live in external libraries — you can't add @Serializable to them. Instead, you write a small KSerializer that teaches kotlinx.serialization how to convert the type to/from a string, then register it once when creating KSafe.
1. Define custom serializers for types you don't own
object UUIDSerializer : KSerializer<UUID> {
override val descriptor = PrimitiveSerialDescriptor("UUID", PrimitiveKind.STRING)
override fun serialize(encoder: Encoder, value: UUID) = encoder.encodeString(value.toString())
override fun deserialize(decoder: Decoder): UUID = UUID.fromString(decoder.decodeString())
}
object InstantSerializer : KSerializer<Instant> {
override val descriptor = PrimitiveSerialDescriptor("Instant", PrimitiveKind.STRING)
override fun serialize(encoder: Encoder, value: Instant) = encoder.encodeString(value.toString())
override fun deserialize(decoder: Decoder): Instant = Instant.parse(decoder.decodeString())
}2. Build a Json instance and register all your serializers in one place
val customJson = Json {
ignoreUnknownKeys = true
serializersModule = SerializersModule {
contextual(UUIDSerializer)
contextual(InstantSerializer)
// add as many as you need
}
}3. Pass it via KSafeConfig — one setup, used everywhere
val ksafe = KSafe(
context = context, // Android; omit on JVM/iOS/WASM
config = KSafeConfig(json = customJson)
)4. Use @Contextual types directly — no extra work at the call site
@Serializable
data class UserProfile(
val name: String,
@Contextual val id: UUID,
@Contextual val createdAt: Instant
)
// Works with all KSafe APIs
ksafe.putDirect("profile", UserProfile("Alice", UUID.randomUUID(), Instant.now()))
val profile: UserProfile = ksafe.getDirect("profile", defaultProfile)
// Suspend API
ksafe.put("profile", profile)
val loaded: UserProfile = ksafe.get("profile", defaultProfile)
// Flow
val profileFlow: Flow<UserProfile> = ksafe.getFlow("profile", defaultProfile)
// Property delegate
var saved: UserProfile by ksafe(defaultProfile, "profile", KSafeWriteMode.Plain)Note: If you don't need custom serializers, you don't need to configure anything — the default
Json { ignoreUnknownKeys = true }is used automatically viaKSafeDefaults.json.
Warning: Changing the
Jsonconfiguration for an existingfileNamenamespace may make previously stored non-primitive values unreadable. Primitives (String,Int,Boolean, etc.) are unaffected.
KSafeWriteMode and encrypted tiers via KSafeEncryptedProtection
var launchCount by ksafe(0) —that is literally it| Feature | SharedPrefs | DataStore | multiplatform-settings | KVault | KSafe |
|---|---|---|---|---|---|
| Thread safety | ❌ ANRs possible | ✅ Coroutine-safe | ✅ Platform-native | ✅ Thread-safe | ✅ ConcurrentHashMap + coroutines |
| Type safety | ❌ Runtime crashes | ✅ Compile-time | ✅ Generic API | ✅ Generic API | ✅ Reified generics + serialization |
| Data corruption | ❌ Crash = data loss | ✅ Atomic | ❌ Platform-dependent | ✅ Atomic | ✅ Uses DataStore atomicity |
| API style | ❌ Callbacks | ✅ Flow | ✅ Sync | ✅ Sync | ✅ Both sync & async |
| Encryption | ❌ None | ❌ None | ❌ None | ✅ Hardware-backed | ✅ Hardware-backed |
| Cross-platform | ❌ Android only | ❌ Android only | ✅ KMP | ✅ KMP | ✅ Android/iOS/JVM/WASM |
| Nullable support | ❌ No | ❌ No | ✅ Yes | ✅ Yes | ✅ Full support |
| Complex types | ❌ Manual | ❌ Manual/Proto | ❌ Manual | ❌ Manual | ✅ Auto-serialization |
| Biometric auth | ❌ Manual | ❌ Manual | ❌ Manual | ❌ Manual | ✅ Built-in |
| Memory policy | N/A | N/A | N/A | N/A | ✅ 3 policies (PLAIN_TEXT / ENCRYPTED / TIMED_CACHE) |
| Hot cache | ✅ Yes | ❌ No | ✅ Yes | ❌ No | ✅ ConcurrentHashMap |
| Write batching | ❌ No | ❌ No | ❌ No | ❌ No | ✅ 16ms coalescing |
Here are benchmark results comparing KSafe against popular Android persistence libraries.
| Library | Read | Write |
|---|---|---|
| SharedPreferences | 0.0017 ms | 0.0224 ms |
| MMKV | 0.0024 ms | 0.0232 ms |
| Multiplatform Settings | 0.0054 ms | 0.0228 ms |
| KSafe (Delegated) | 0.0073 ms | 0.0218 ms |
| DataStore | 0.5549 ms | 5.17 ms |
Note: KSafe unencrypted writes are on par with SharedPreferences (0.0218 ms vs 0.0224 ms) while providing KMP support, type-safe serialization, and optional encryption.
| Library | Time | vs KSafe |
|---|---|---|
| KSafe (PLAIN_TEXT memory) | 0.0174 ms | — |
| KVault | 0.2418 ms | KSafe is 14x faster |
| EncryptedSharedPreferences | 0.2603 ms | KSafe is 15x faster |
| KSafe (ENCRYPTED memory) | 4.93 ms | (real AES-GCM decryption via Keystore on every read) |
Note on ENCRYPTED memory policy: The ENCRYPTED memory policy keeps ciphertext in RAM and performs real AES-GCM decryption through the Android Keystore on every read (~5 ms). This is the cost of hardware-backed cryptography. For most use cases, use
PLAIN_TEXT(decrypts once at init) orENCRYPTED_WITH_TIMED_CACHE(decrypts once per TTL window).
| Library | Time | vs KSafe |
|---|---|---|
| KSafe (PLAIN_TEXT memory) | 0.0254 ms | — |
| KSafe (ENCRYPTED memory) | 0.0347 ms | — |
| EncryptedSharedPreferences | 0.2234 ms | KSafe is 9x faster |
| KVault | 0.8516 ms | KSafe is 34x faster |
vs DataStore (KSafe's backend):
vs KVault (encrypted KMP storage):
vs EncryptedSharedPreferences:
vs SharedPreferences (unencrypted baseline):
vs multiplatform-settings (Russell Wolf):
How fast can each library load existing data on app startup?
| Library | Keys | Time |
|---|---|---|
| SharedPreferences | 501 | 0.032 ms |
| Multiplatform Settings | 501 | 0.109 ms |
| MMKV | 501 | 0.119 ms |
| DataStore | 501 | 0.559 ms |
| KSafe (ENCRYPTED) | 1503 | 18.2 ms |
| KSafe (PLAIN_TEXT) | 3006 | 45.7 ms |
| EncryptedSharedPrefs | 501 | 56.2 ms |
| KVault | 650 | 58.3 ms |
Note: KSafe ENCRYPTED mode is 2.5x faster to cold-start than PLAIN_TEXT mode. This is because ENCRYPTED defers decryption until values are accessed, while PLAIN_TEXT decrypts all values upfront during initialization. Both KSafe modes cold-start faster than EncryptedSharedPreferences and KVault.
KSafe uses a hot cache architecture similar to SharedPreferences, but built on top of DataStore:
Vanilla DataStore:
Read: suspend → Flow.first() → disk I/O → ~0.55 ms
Write: suspend → edit{} → serialize → disk I/O → ~5.2 ms
KSafe with Hot Cache:
Read: getDirect() → ConcurrentHashMap lookup → ~0.007 ms (no disk!)
Write: putDirect() → update HashMap + queue → ~0.022 ms (returns immediately)
Background: batched DataStore.edit() (user doesn't wait)
Key optimizations:
This means KSafe gives you DataStore's safety guarantees (atomic transactions, type-safe) with SharedPreferences-level performance.
| Platform | Minimum Version | Notes |
|---|---|---|
| Android | API 23 (Android 6.0) | Hardware-backed Keystore on supported devices |
| iOS | iOS 13+ | Keychain-backed symmetric keys (protected by device passcode) |
| JVM/Desktop | JDK 11+ | Software-backed encryption |
| WASM/Browser | Any modern browser | WebCrypto API + localStorage |
| Dependency | Tested Version |
|---|---|
| Kotlin | 2.0.0+ |
| Kotlin Coroutines | 1.8.0+ |
| DataStore Preferences | 1.1.0+ |
| Compose Multiplatform | 1.6.0+ (for ksafe-compose) |
You can create multiple KSafe instances with different file names to separate different types of data:
class MyViewModel : ViewModel() {
private val userPrefs = KSafe(fileName = "userpreferences")
private val appSettings = KSafe(fileName = "appsettings")
private val cacheData = KSafe(fileName = "cache")
// For named instances, use suspend or direct APIs:
suspend fun saveUserToken(token: String) {
userPrefs.put("auth_token", token)
}
}Important Instance Management Rules:
// ✅ Good: Singleton instances via DI
val appModule = module {
single { KSafe() } // Default instance
single(named("user")) { KSafe(fileName = "userdata") }
single(named("cache")) { KSafe(fileName = "cache") }
}
// ❌ Bad: Creating multiple instances for the same file
class ScreenA { val prefs = KSafe(fileName = "userdata") }
class ScreenB { val prefs = KSafe(fileName = "userdata") } // DON'T DO THIS!File Name Requirements:
"userdata", "settings", "cache"
KSafe provides a standalone biometric authentication helper that works on both Android and iOS. This is a general-purpose utility that can protect any action in your app—not just KSafe persistence operations.
| Method | Type | Use Case |
|---|---|---|
verifyBiometricDirect(reason, authorizationDuration?) { success -> } |
Callback-based | Simple, non-blocking, works anywhere |
verifyBiometric(reason, authorizationDuration?): Boolean |
Suspend function | Coroutine-based, cleaner async code |
class MyViewModel(private val ksafe: KSafe) : ViewModel() {
var secureCounter by ksafe.mutableStateOf(0)
private set
// Always prompt (no caching)
fun incrementWithBiometric() {
ksafe.verifyBiometricDirect("Authenticate to increment") { success ->
if (success) secureCounter++
}
}
// Coroutine-based approach
fun incrementWithBiometricSuspend() {
viewModelScope.launch {
if (ksafe.verifyBiometric("Authenticate to increment")) {
secureCounter++
}
}
}
}Avoid repeated biometric prompts by caching successful authentication:
data class BiometricAuthorizationDuration(
val duration: Long, // Duration in milliseconds
val scope: String? = null // Optional scope identifier (null = global)
)
// Cache for 60 seconds (scoped to this ViewModel)
ksafe.verifyBiometricDirect(
reason = "Authenticate",
authorizationDuration = BiometricAuthorizationDuration(
duration = 60_000L,
scope = viewModelScope.hashCode().toString()
)
) { success -> /* ... */ }| Parameter | Meaning |
|---|---|
authorizationDuration = null |
Always prompt (no caching) |
duration > 0 |
Cache auth for this many milliseconds |
scope = null |
Global scope - any call benefits from cached auth |
scope = "xyz" |
Scoped auth - only calls with same scope benefit |
// ViewModel-scoped: auth invalidates when ViewModel is recreated
BiometricAuthorizationDuration(60_000L, viewModelScope.hashCode().toString())
// User-scoped: auth invalidates on user change
BiometricAuthorizationDuration(300_000L, "user_$userId")
// Flow-scoped: auth shared across a multi-step flow
BiometricAuthorizationDuration(120_000L, "checkout_flow")ksafe.clearBiometricAuth() // Clear all cached authorizations
ksafe.clearBiometricAuth("settings") // Clear specific scope only// Protect API calls
fun deleteAccount() {
ksafe.verifyBiometricDirect("Confirm account deletion") { success ->
if (success) api.deleteAccount()
}
}
// Protect navigation
fun navigateToSecrets() {
ksafe.verifyBiometricDirect("Authenticate to view secrets") { success ->
if (success) navController.navigate("secrets")
}
}Permission - Add to AndroidManifest.xml:
<uses-permission android:name="android.permission.USE_BIOMETRIC" />Activity Requirement - BiometricPrompt requires FragmentActivity or AppCompatActivity:
// ❌ Won't work with biometrics
class MainActivity : ComponentActivity()
// ✅ Works with biometrics
class MainActivity : AppCompatActivity()Early Initialization - KSafe must be initialized before any Activity is created:
class MyApplication : Application() {
override fun onCreate() {
super.onCreate()
startKoin {
androidContext(this@MyApplication)
modules(appModule)
}
get<KSafe>() // Force initialization
}
}Customizing the Prompt:
BiometricHelper.promptTitle = "Unlock Secure Data"
BiometricHelper.promptSubtitle = "Authenticate to continue"Info.plist - Add Face ID usage description:
<key>NSFaceIDUsageDescription</key>
<string>Authenticate to access secure data</string>Note: On iOS Simulator, biometric verification always returns true since there's no biometric hardware.
class SecureViewModel(private val ksafe: KSafe) : ViewModel() {
// Regular persisted counter (no biometric)
var counter by ksafe.mutableStateOf(0)
private set
// Counter that requires biometric to increment
var bioCounter by ksafe.mutableStateOf(0)
private set
fun incrementCounter() {
counter++ // No biometric prompt
}
// Always prompt
fun incrementBioCounter() {
ksafe.verifyBiometricDirect("Authenticate to save") { success ->
if (success) {
bioCounter++
}
}
}
// With 60s duration caching (scoped to this ViewModel instance)
fun incrementBioCounterCached() {
ksafe.verifyBiometricDirect(
reason = "Authenticate to save",
authorizationDuration = BiometricAuthorizationDuration(
duration = 60_000L,
scope = viewModelScope.hashCode().toString()
)
) { success ->
if (success) {
bioCounter++
}
}
}
// Suspend function with caching
fun incrementBioCounterAsync() {
viewModelScope.launch {
val authDuration = BiometricAuthorizationDuration(
duration = 60_000L,
scope = viewModelScope.hashCode().toString()
)
if (ksafe.verifyBiometric("Authenticate to save", authDuration)) {
bioCounter++
}
}
}
// Call on logout to force re-authentication
fun onLogout() {
ksafe.clearBiometricAuth() // Clear all cached auth
}
}Key Points:
verifyBiometricDirect) and suspend (verifyBiometric)BiometricAuthorizationDuration
AppCompatActivity and early KSafe initializationKSafe can detect and respond to runtime security threats:
val ksafe = KSafe(
context = context,
securityPolicy = KSafeSecurityPolicy(
rootedDevice = SecurityAction.WARN, // IGNORE, WARN, or BLOCK
debuggerAttached = SecurityAction.BLOCK,
debugBuild = SecurityAction.WARN,
emulator = SecurityAction.IGNORE,
onViolation = { violation ->
analytics.log("Security: ${violation.name}")
}
)
)| Check | Android | iOS | JVM | WASM | Description |
|---|---|---|---|---|---|
rootedDevice |
✅ | ✅ | ❌ | ❌ | Detects rooted/jailbroken devices |
debuggerAttached |
✅ | ✅ | ✅ | ❌ | Detects attached debuggers |
debugBuild |
✅ | ✅ | ✅ | ❌ | Detects debug builds |
emulator |
✅ | ✅ | ❌ | ❌ | Detects emulators/simulators |
| Action | Behavior | Use Case |
|---|---|---|
IGNORE |
No detection performed | Development, non-sensitive apps |
WARN |
Callback invoked, app continues | Logging/analytics, user warnings |
BLOCK |
Callback invoked, throws SecurityViolationException
|
Banking, enterprise apps |
Example behavior with WARN:
val ksafe = KSafe(
context = context,
securityPolicy = KSafeSecurityPolicy(
rootedDevice = SecurityAction.WARN,
onViolation = { violation ->
// This is called, but app continues working
showWarningDialog("Security risk: ${violation.name}")
analytics.log("security_warning", violation.name)
}
)
)
// KSafe initializes successfully, user sees warningExample behavior with BLOCK:
val ksafe = KSafe(
context = context,
securityPolicy = KSafeSecurityPolicy(
rootedDevice = SecurityAction.BLOCK,
onViolation = { violation ->
// This is called BEFORE the exception is thrown
analytics.log("security_block", violation.name)
}
)
)
// If device is rooted: SecurityViolationException is thrown
// App must catch this or it will crashKSafeSecurityPolicy.Default // All checks ignored (backwards compatible)
KSafeSecurityPolicy.Strict // Blocks on root/debugger, warns on debug/emulator
KSafeSecurityPolicy.WarnOnly // Warns on everything, never blockstry {
val ksafe = KSafe(context, securityPolicy = KSafeSecurityPolicy.Strict)
} catch (e: SecurityViolationException) {
showSecurityErrorScreen(e.violation.name)
}Since SecurityViolation is an enum without hardcoded messages, provide your own descriptions:
fun getViolationDescription(violation: SecurityViolation): String {
return when (violation) {
SecurityViolation.RootedDevice ->
"The device is rooted (Android) or jailbroken (iOS). " +
"This allows apps to bypass sandboxing and potentially access encrypted data."
SecurityViolation.DebuggerAttached ->
"A debugger is attached to the process. " +
"This allows inspection of memory and runtime values including decrypted secrets."
SecurityViolation.DebugBuild ->
"The app is running in debug mode. " +
"Debug builds may have weaker security settings and expose more information."
SecurityViolation.Emulator ->
"The app is running on an emulator/simulator. " +
"Emulators don't have hardware-backed security like real devices."
}
}Since KSafe initializes before ViewModels, use a holder to bridge violations to your UI:
// 1. Create a holder to collect violations during initialization
object SecurityViolationsHolder {
private val _violations = mutableListOf<SecurityViolation>()
val violations: List<SecurityViolation> get() = _violations.toList()
fun addViolation(violation: SecurityViolation) {
if (violation !in _violations) {
_violations.add(violation)
}
}
}
// 2. Configure KSafe to populate the holder
val ksafe = KSafe(
context = context,
securityPolicy = KSafeSecurityPolicy.Strict.copy(
onViolation = { violation ->
SecurityViolationsHolder.addViolation(violation)
}
)
)
// 3. Read from the holder in your ViewModel
class SecurityViewModel : ViewModel() {
val violations = mutableStateListOf<UiSecurityViolation>()
init {
SecurityViolationsHolder.violations.forEach { violation ->
violations.add(UiSecurityViolation(violation))
}
}
}When using SecurityViolation in Jetpack Compose, the Compose compiler treats it as "unstable" because it resides in the core ksafe module. The ksafe-compose module provides UiSecurityViolation—a wrapper marked with @Immutable:
@Immutable
data class UiSecurityViolation(
val violation: SecurityViolation
)| Type | Compose Stability |
|---|---|
ImmutableList<SecurityViolation> |
Unstable (causes recomposition) |
ImmutableList<UiSecurityViolation> |
Stable (enables skipping) |
The KSafeDemo app makes use of UiSecurityViolation—visit the demo application's source to see it in action.
su binary paths (/system/bin/su, /system/xbin/su, etc.)/sbin/.magisk, /data/adb/magisk, etc.)test-keys) and dangerous system properties/bin/bash, /usr/sbin/sshd, etc.)Limitation: Sophisticated root-hiding tools (Magisk DenyList, Shamiko, Zygisk) can bypass most client-side detection methods.
KSafe provides enterprise-grade encrypted persistence using DataStore Preferences with platform-specific secure key storage.
| Platform | Cipher | Key Storage | Security |
|---|---|---|---|
| Android | AES-256-GCM | Android Keystore — TEE by default, StrongBox opt-in | Keys non-exportable, app-bound, auto-deleted on uninstall |
| iOS | AES-256-GCM via CryptoKit | iOS Keychain Services — Secure Enclave opt-in | Protected by device passcode/biometrics, not in backups |
| JVM/Desktop | AES-256-GCM via javax.crypto | Software-backed in ~/.eu_anifantakis_ksafe/
|
Relies on OS file permissions (0700 on POSIX) |
| WASM/Browser | AES-256-GCM via WebCrypto |
localStorage (Base64-encoded) |
Scoped per origin, ~5-10 MB limit |
__ksafe_value_<key>__ksafe_meta_<key>__ as compact JSON{"v":1,"p":"DEFAULT"} or {"v":1,"p":"DEFAULT","u":"unlocked"})What is GCM? GCM (Galois/Counter Mode) is an authenticated encryption mode that provides both confidentiality and integrity. The authentication tag detects any tampering—if someone modifies even a single bit of the ciphertext, decryption will fail.
What KSafe protects against:
What KSafe does NOT protect against:
ENCRYPTED or ENCRYPTED_WITH_TIMED_CACHE memory policy)Recommendations:
KSafeSecurityPolicy.Strict for high-security apps (Banking, Medical, Enterprise)KSafeMemoryPolicy.ENCRYPTED for highly sensitive data (tokens, passwords)KSafeMemoryPolicy.ENCRYPTED_WITH_TIMED_CACHE for encrypted data accessed frequently during UI rendering (Compose recomposition, SwiftUI re-render)A note on hardware security models: By default, Android stores AES keys in the TEE (Trusted Execution Environment) — a hardware-isolated zone on the main processor where encryption happens entirely on-chip and the key never enters app memory. With mode = KSafeWriteMode.Encrypted(KSafeEncryptedProtection.HARDWARE_ISOLATED), KSafe targets a physically separate security chip (StrongBox on Android, Secure Enclave on iOS) with automatic fallback to default hardware. On iOS, HARDWARE_ISOLATED uses envelope encryption: an EC P-256 key pair in the Secure Enclave wraps/unwraps the AES symmetric key via ECIES, so the AES key material is hardware-protected even though AES-GCM itself runs in CryptoKit. Without hardware isolation, AES keys are stored as Keychain items — still encrypted by the OS and protected by the device passcode.
Hardware isolation (per-property):
// StrongBox on Android, Secure Enclave on iOS
var secret by ksafe(
"",
mode = KSafeWriteMode.Encrypted(KSafeEncryptedProtection.HARDWARE_ISOLATED)
)
// Or with suspend/direct API
ksafe.put("secret", value, mode = KSafeWriteMode.Encrypted(KSafeEncryptedProtection.HARDWARE_ISOLATED))
ksafe.putDirect("secret", value, mode = KSafeWriteMode.Encrypted(KSafeEncryptedProtection.HARDWARE_ISOLATED))Hardware isolation provides the highest security level — keys live on a dedicated chip that is physically separate from the main processor. If the device lacks the hardware, KSafe automatically falls back to the platform default with no code changes required. Note that hardware-isolated key generation is slower and per-operation latency is higher, so only enable it for high-security use cases. KSafe's memory policies mitigate read-side latency since most reads come from the hot cache.
Migrating existing keys to hardware isolation: Using HARDWARE_ISOLATED only affects new key generation. Existing keys continue working from wherever they were originally generated. To migrate existing data to hardware-isolated keys, delete the KSafe data (or the specific keys) and reinitialize.
Per-key metadata (single entry): Each key stores one metadata entry (__ksafe_meta_{key}__) that includes:
p → protection tier (NONE, DEFAULT, HARDWARE_ISOLATED)u → unlock policy ("unlocked" when requireUnlockedDevice=true)This metadata is used for read auto-detection and getKeyInfo().
Legacy metadata (__ksafe_prot_{key}__) is still read for backward compatibility and cleaned on next write/delete.
KSafe exposes properties and methods to query what security hardware is available on the device, and to inspect both the protection tier (what the caller requested) and storage location (where the key material actually lives) of individual keys:
val ksafe = KSafe(context)
// Device-level: what hardware is available?
ksafe.deviceKeyStorages // e.g. {HARDWARE_BACKED, HARDWARE_ISOLATED}
ksafe.deviceKeyStorages.max() // HARDWARE_ISOLATED (highest available)
// Per-key: what protection was used and where is the key stored?
val info = ksafe.getKeyInfo("auth_token")
// info?.protection → KSafeProtection.DEFAULT (encrypted tier, null if plaintext)
// info?.storage → KSafeKeyStorage.HARDWARE_BACKED (where the key lives)getKeyInfo returns a KSafeKeyInfo data class:
data class KSafeKeyInfo(
val protection: KSafeProtection?, // null, DEFAULT, or HARDWARE_ISOLATED
val storage: KSafeKeyStorage // SOFTWARE, HARDWARE_BACKED, or HARDWARE_ISOLATED
)The KSafeKeyStorage enum has three levels with natural ordinal ordering:
| Level | Meaning | Platforms |
|---|---|---|
SOFTWARE |
Software-only (file system / localStorage) | JVM, WASM |
HARDWARE_BACKED |
On-chip hardware (TEE / Keychain) | Android, iOS |
HARDWARE_ISOLATED |
Dedicated security chip (StrongBox / Secure Enclave) | Android (if available), iOS (real devices) |
Query what hardware security levels the device supports:
| Platform | deviceKeyStorages |
|---|---|
| Android | Always {HARDWARE_BACKED}. Adds HARDWARE_ISOLATED if PackageManager.FEATURE_STRONGBOX_KEYSTORE is present (API 28+). |
| iOS | Always {HARDWARE_BACKED}. Adds HARDWARE_ISOLATED on real devices (not simulator). |
| JVM | {SOFTWARE} |
| WASM | {SOFTWARE} |
Query the protection tier and storage location of a specific key:
ksafe.getKeyInfo("auth_token") // KSafeKeyInfo(DEFAULT, HARDWARE_BACKED) on Android/iOS
ksafe.getKeyInfo("theme") // KSafeKeyInfo(null, SOFTWARE) if unencrypted
ksafe.getKeyInfo("nonexistent") // null (key doesn't exist)| Scenario | Return value |
|---|---|
| Key not found | null |
| Unencrypted key | KSafeKeyInfo(null, SOFTWARE) |
| Encrypted key (Android/iOS) | KSafeKeyInfo(DEFAULT, HARDWARE_BACKED) |
| Encrypted key (JVM/WASM) | KSafeKeyInfo(DEFAULT, SOFTWARE) |
HARDWARE_ISOLATED key (device supports it) |
KSafeKeyInfo(HARDWARE_ISOLATED, HARDWARE_ISOLATED) |
HARDWARE_ISOLATED key (device lacks it, fell back) |
KSafeKeyInfo(HARDWARE_ISOLATED, HARDWARE_BACKED) |
Use cases:
KSafeEncryptedProtection level based on what the device supports// Adaptive protection based on device capabilities
val protection = if (KSafeKeyStorage.HARDWARE_ISOLATED in ksafe.deviceKeyStorages)
KSafeEncryptedProtection.HARDWARE_ISOLATED
else
KSafeEncryptedProtection.DEFAULT
var secret by ksafe("", mode = KSafeWriteMode.Encrypted(protection))
// Verify a key's actual storage level after writing
ksafe.putDirect("secret", "value", mode = KSafeWriteMode.Encrypted(KSafeEncryptedProtection.HARDWARE_ISOLATED))
val info = ksafe.getKeyInfo("secret")
// info?.protection == HARDWARE_ISOLATED (always matches what was requested)
// info?.storage == HARDWARE_ISOLATED on devices with StrongBox/SE, HARDWARE_BACKED otherwiseKSafe now uses canonical, namespaced storage keys:
__ksafe_value_{key}
__ksafe_meta_{key}__
Legacy keys are still supported on reads:
encrypted_{key}{key}
__ksafe_prot_{key}__Migration is lazy and safe:
put/putDirect) always persist canonical keys and remove legacy entries for that key.Control the trade-off between performance and security for data in RAM:
val ksafe = KSafe(
fileName = "secrets",
memoryPolicy = KSafeMemoryPolicy.ENCRYPTED // Default
)| Policy | Best For | RAM Contents | Read Cost | Security |
|---|---|---|---|---|
PLAIN_TEXT |
User settings, themes | Plaintext (forever) | O(1) lookup | Low — all data exposed in memory |
ENCRYPTED (Default) |
Tokens, passwords | Ciphertext only | AES-GCM decrypt every read | High — nothing plaintext in RAM |
ENCRYPTED_WITH_TIMED_CACHE |
Compose/SwiftUI screens | Ciphertext + short-lived plaintext | First read decrypts, then O(1) for TTL | Medium — plaintext only for recently-accessed keys, only for seconds |
All three policies encrypt data on disk. The difference is how data is handled in memory:
Under ENCRYPTED policy, every read triggers AES-GCM decryption. In UI frameworks like Jetpack Compose or SwiftUI, the same encrypted property may be read multiple times during a single recomposition/re-render. ENCRYPTED_WITH_TIMED_CACHE eliminates redundant crypto: only the first read decrypts; subsequent reads within the TTL window are pure memory lookups.
val ksafe = KSafe(
context = context,
memoryPolicy = KSafeMemoryPolicy.ENCRYPTED_WITH_TIMED_CACHE,
plaintextCacheTtl = 5.seconds // default; how long plaintext stays cached
)How it works internally:
Read 1: decrypt → cache plaintext (TTL=5s) → return ← one crypto operation
Read 2 (50ms later): cache hit → return ← no decryption
Read 3 (100ms later): cache hit → return ← no decryption
...TTL expires...
Read 4: decrypt → cache plaintext (TTL=5s) → return ← one crypto operation
Thread safety: Reads capture a local reference to the cached entry atomically. No background sweeper — expired entries are simply ignored on the next access. No race conditions possible.
val archive = KSafe(
fileName = "archive",
lazyLoad = true // Skip preload, load on first request
)// Android
KSafe(
context: Context,
fileName: String? = null,
lazyLoad: Boolean = false,
memoryPolicy: KSafeMemoryPolicy = KSafeMemoryPolicy.ENCRYPTED,
config: KSafeConfig = KSafeConfig(),
securityPolicy: KSafeSecurityPolicy = KSafeSecurityPolicy.Default,
plaintextCacheTtl: Duration = 5.seconds // only used with ENCRYPTED_WITH_TIMED_CACHE
)
// iOS / JVM / WASM
KSafe(
fileName: String? = null,
lazyLoad: Boolean = false,
memoryPolicy: KSafeMemoryPolicy = KSafeMemoryPolicy.ENCRYPTED,
config: KSafeConfig = KSafeConfig(),
securityPolicy: KSafeSecurityPolicy = KSafeSecurityPolicy.Default,
plaintextCacheTtl: Duration = 5.seconds // only used with ENCRYPTED_WITH_TIMED_CACHE
)val ksafe = KSafe(
context = context,
config = KSafeConfig(
keySize = 256, // AES key size: 128 or 256 bits
requireUnlockedDevice = false // Default for protection-based encrypted writes
)
)Note: The encryption algorithm (AES-GCM) is intentionally NOT configurable to prevent insecure configurations.
Control whether encrypted data is only accessible when the device is unlocked.
You now have two options:
KSafeWriteMode.Encrypted(requireUnlockedDevice = ...)
KSafeConfig(requireUnlockedDevice = ...) for no-mode encrypted writes (put/putDirect without mode)// Per-entry policy (recommended)
ksafe.put(
"auth_token",
token,
mode = KSafeWriteMode.Encrypted(
protection = KSafeEncryptedProtection.DEFAULT,
requireUnlockedDevice = true
)
)
// Fallback default for no-mode encrypted writes
val ksafe = KSafe(
context = context,
config = KSafeConfig(requireUnlockedDevice = true)
)
| Platform |
false (default) |
true |
|---|---|---|
| Android | Keys accessible at any time |
setUnlockedDeviceRequired(true) (API 28+) |
| iOS | AfterFirstUnlockThisDeviceOnly |
WhenUnlockedThisDeviceOnly |
| JVM | No effect (software keys) | No effect (software keys) |
| WASM | No effect (browser has no lock concept) | No effect |
Important: requireUnlockedDevice applies only to encrypted writes.
KSafeWriteMode.Plain intentionally does not use unlock policy.
Metadata shape: unlock policy is recorded per key in __ksafe_meta_{key}__ JSON ("u":"unlocked" only when enabled). There is no global per-instance access-policy marker.
Error behavior when locked: When requireUnlockedDevice = true and the device is locked, encrypted reads (getDirect, get, getFlow) throw IllegalStateException. The suspend put() also throws for encrypted data. However, putDirect does not throw to the caller — it queues the write to a background consumer that logs the error and drops the batch (the consumer stays alive for future writes after the device is unlocked). Your app can catch read-side exceptions to show a "device is locked" message instead of silently receiving default values.
You can still use multiple instances for hard separation (for example, secure and prefs), but it is no longer required for lock-policy control because policy can be set per write entry.
// Android example with Koin
actual val platformModule = module {
// Sensitive data: only accessible when device is unlocked
single(named("secure")) {
KSafe(
context = androidApplication(),
fileName = "secure",
config = KSafeConfig(requireUnlockedDevice = true)
)
}
// General preferences: accessible even when locked (e.g., for background sync)
single(named("prefs")) {
KSafe(
context = androidApplication(),
fileName = "prefs",
config = KSafeConfig(requireUnlockedDevice = false)
)
}
}
// Usage in ViewModel
class MyViewModel(
private val secureSafe: KSafe, // tokens, passwords — locked when device is locked
private val prefsSafe: KSafe // settings, cache — always accessible
) : ViewModel() {
var authToken by secureSafe("")
var lastSyncTime by prefsSafe(0L)
}This pattern is especially useful for apps that perform background work (push notifications, sync) while the device is locked — the background-safe instance can still access its data, while the secure instance protects sensitive values.
KSafe 1.2.0 introduced a completely rewritten core architecture focusing on zero-latency UI performance.
Before (v1.1.x): Every getDirect() call triggered a blocking disk read and decryption on the calling thread.
Now (v1.2.0): Data is preloaded asynchronously on initialization. getDirect() performs an Atomic Memory Lookup (O(1)), returning instantly.
Safety: If data is accessed before the preload finishes, the library automatically falls back to a blocking read.
putDirect() updates the in-memory cache immediately, allowing your UI to reflect changes instantly while disk encryption happens in the background.
┌─────────────────────────────────────────────────────────────┐
│ KSafe API │
│ (get, put, getDirect, putDirect, delete) │
└─────────────────────────┬───────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ KSafeConfig │
│ (keySize) │
└─────────────────────────┬───────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ KSafeEncryption Interface │
│ encrypt() / decrypt() / deleteKey() │
└─────────────────────────┬───────────────────────────────────┘
│
┌───────────────┼───────────────┬───────────────┐
▼ ▼ ▼ ▼
┌─────────────────┐ ┌───────────────┐ ┌─────────────┐ ┌─────────────┐
│ Android │ │ iOS │ │ JVM │ │ WASM │
│ Keystore │ │ Keychain │ │ Software │ │ WebCrypto │
│ Encryption │ │ Encryption │ │ Encryption │ │ Encryption │
└─────────────────┘ └───────────────┘ └─────────────┘ └─────────────┘
KSafeEncryptedProtection.HARDWARE_ISOLATED (through KSafeWriteMode.Encrypted) — uses a physically separate security chip with automatic TEE fallback on devices without StrongBoxKSafeEncryptedProtection.HARDWARE_ISOLATED (through KSafeWriteMode.Encrypted) — uses envelope encryption (SE-backed EC P-256 wraps/unwraps the AES key) with automatic Keychain fallback on devices without SElocalStorage (Base64-encoded)PLAIN_TEXT internally (WebCrypto is async-only)KSafe's hardware-backed encryption has been tested and verified on real devices:
| Platform | Device | Hardware Security |
|---|---|---|
| iOS | iPhone 15 Pro Max (A17 Pro) | Secure Enclave |
| Android | Samsung Galaxy S24 Ultra (Snapdragon 8 Gen 3) | StrongBox (Knox Vault) |
If decryption fails (e.g., corrupted data or missing key), KSafe gracefully returns the default value, ensuring your app continues to function.
Exception: When requireUnlockedDevice = true and the device is locked, KSafe throws IllegalStateException instead of returning the default value. This allows your app to detect and handle the locked state explicitly (e.g., showing a "device is locked" message).
KSafe ensures clean reinstalls on all platforms:
Note on unencrypted values: The orphaned ciphertext cleanup targets only encrypted entries (those with the
encrypted_prefix in DataStore). Unencrypted values (encrypted = false) are not affected by this cleanup. On Android, ifandroid:allowBackup="true"is set in the manifest, Auto Backup may restore unencrypted DataStore entries after reinstall with stale values from the last backup snapshot.
On startup, KSafe probes each encrypted DataStore entry by attempting decryption:
requireUnlockedDevice setting (default: accessible after first unlock)setUnlockedDeviceRequired requires API 28+localStorage which can be cleared by the user. Security checks (root, debugger, emulator) are no-ops# Run all tests across all platforms
./gradlew allTests
# Run common tests only
./gradlew :ksafe:commonTest
# Run JVM tests
./gradlew :ksafe:jvmTest
# Run Android unit tests (Note: May fail in Robolectric due to KeyStore limitations)
./gradlew :ksafe:testDebugUnitTest
# Run Android instrumented tests on connected device/emulator (Recommended for Android)
./gradlew :ksafe:connectedDebugAndroidTest
# Run iOS tests on simulator
./gradlew :ksafe:iosSimulatorArm64Test
# Run a specific test class
./gradlew :ksafe:commonTest --tests "*.KSafeTest"Note: iOS Simulator uses real Keychain APIs (software-backed), while real devices store Keychain data in a hardware-encrypted container protected by the device passcode.
./gradlew :ksafe:linkDebugFrameworkIosSimulatorArm64 # For simulator
./gradlew :ksafe:linkDebugFrameworkIosArm64 # For physical devicecd iosTestApp
xcodebuild -scheme KSafeTestApp \
-configuration Debug \
-sdk iphonesimulator \
-arch arm64 \
-derivedDataPath build \
buildxcrun simctl list devices | grep "Booted"
xcrun simctl install DEVICE_ID build/Build/Products/Debug-iphonesimulator/KSafeTestApp.app
xcrun simctl launch DEVICE_ID com.example.KSafeTestAppcd iosTestApp
xcodebuild -scheme KSafeTestApp \
-configuration Debug \
-sdk iphoneos \
-derivedDataPath build \
buildImportant Notes:
The iOS test app demonstrates:
putDirect to immediately update valuesThe encrypted: Boolean parameter on all API methods is deprecated at DeprecationLevel.WARNING — code using it still compiles but shows strikethrough warnings in the IDE with one-click ReplaceWith auto-fix. Migrate to KSafeWriteMode:
// Old (WARNING — still compiles but deprecated)
ksafe.put("key", value, encrypted = true)
ksafe.get("key", "", encrypted = false)
// New — writes specify mode, reads auto-detect
ksafe.put("key", value) // encrypted default
ksafe.put("key", value, mode = KSafeWriteMode.Plain) // unencrypted
val v = ksafe.get("key", "") // auto-detectsThe mapping is: encrypted = true → KSafeWriteMode.Encrypted(), encrypted = false → KSafeWriteMode.Plain.
KSafe now writes:
__ksafe_value_{key}
__ksafe_meta_{key}__
Legacy keys (encrypted_{key}, bare {key}, __ksafe_prot_{key}__) are still readable and are cleaned when that key is next written/deleted.
Read methods (get, getDirect, getFlow, getStateFlow) no longer accept a protection parameter. They automatically detect whether stored data is encrypted from persisted metadata. You specify write behavior via mode:
// Writes — specify mode
ksafe.put("secret", token) // encrypted (default)
ksafe.putDirect("theme", "dark", mode = KSafeWriteMode.Plain) // unencrypted
var pin by ksafe(
"",
mode = KSafeWriteMode.Encrypted(KSafeEncryptedProtection.HARDWARE_ISOLATED)
) // StrongBox / SE
// Reads — auto-detect, no protection needed
val secret = ksafe.get("secret", "")
val theme = ksafe.getDirect("theme", "light")
val flow = ksafe.getFlow("secret", "")This eliminates the common mistake of mismatching protection levels between put and get calls.
The public API surface (get, put, getDirect, putDirect) remains backward compatible.
lazyLoad = true.null values.If upgrading from early 1.2.0 alphas, update your imports:
// Old (broken in alpha versions)
import eu.eu.anifantakis.lib.ksafe.compose.mutableStateOf
// New (correct)
import eu.anifantakis.lib.ksafe.compose.mutableStateOf| Feature | KSafe | EncryptedSharedPrefs | KVault | Multiplatform Settings | SQLCipher |
|---|---|---|---|---|---|
| KMP Support | ✅ Android, iOS, JVM, WASM | ❌ Android only | ✅ Android, iOS | ✅ Multi-platform | |
| Hardware-backed Keys | ✅ Keystore/Keychain | ✅ Keystore | ✅ Keystore/Keychain | ❌ No encryption | ❌ Software |
| Zero Boilerplate | ✅ by ksafe(0)
|
❌ Verbose API | ❌ SQL required | ||
| Biometric Helper | ✅ Built-in | ❌ Manual | ❌ Manual | ❌ Manual | ❌ Manual |
| Compose State | ✅ mutableStateOf
|
❌ Manual | ❌ Manual | ❌ Manual | ❌ Manual |
| Type Safety | ✅ Reified generics | ✅ Good | ✅ Good | ❌ SQL strings | |
| Auth Caching | ✅ Scoped sessions | ❌ No | ❌ No | ❌ No | ❌ No |
When to choose KSafe:
by ksafe(x)) for minimal boilerplateWhen to consider alternatives:
Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License.
You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License.