Skip to content

ioannisa/KSafe

Repository files navigation

KSafe — Universal Key/Value Persistence for Kotlin Multiplatform and Android

  • Encrypted by default. Plain (unencrypted) when needed.
  • Persist variables, Compose State, StateFlow, and serializable objects across Android, iOS, macOS, Desktop, and Web
  • Easy to use by design

Maven Central License

image

🤖 KSafe Skill for AI agents

KSafe ships KSAFE_SKILL.md — an agentskills.io-compatible skill that teaches any AI agent (Claude Code, Codex, Gemini CLI, Copilot CLI, Junie) KSafe's patterns, anti-patterns, and gotchas. Restart your agent session after installing — skills load at session start.

Install — copy the skill into every supported agent's skills directory
for agent in claude codex gemini copilot junie; do
  mkdir -p "$HOME/.$agent/skills/ksafe" && \
    curl -fsSL https://raw.githubusercontent.com/ioannisa/KSafe/main/KSAFE_SKILL.md \
    > "$HOME/.$agent/skills/ksafe/SKILL.md"
done

Edit the loop to skip agents you don't use. If you've already cloned this repo, cp KSAFE_SKILL.md "$HOME/.<agent>/skills/ksafe/SKILL.md" works too (faster, offline).

What is KSafe?

KSafe is a secure-by-default Kotlin Multiplatform key/value persistence library. Persist ordinary Kotlin variables, Compose MutableState, MutableStateFlow, and @Serializable objects across app restarts with one API on Android, iOS, macOS, JVM/Desktop, WASM, and Kotlin/JS. Encrypted (AES-256-GCM) by default; plain per-entry with mode = KSafeWriteMode.Plain.

var counter by ksafe(0)
counter++   // auto-encrypted (AES-256-GCM), auto-persisted, survives process death

Read and write it like any normal Kotlin variable — no suspend, no runBlocking, no DataStore boilerplate, no explicit encrypt/decrypt. Reads hit a hot in-memory cache (~0.002 ms); writes encrypt and flush in the background — synchronous, but never blocking. Reach for the suspend API (get / put) only when you want to await the disk flush.

  • Easy? ✔ one-line setup, property-delegate API
  • Encrypted by default? ✔ AES-256-GCM, hardware-backed where available
  • Plain storage? ✔ opt out with one parameter
  • Synchronous? ✔ non-blocking hot-cache reads
  • Asynchronous? ✔ full suspend API for guaranteed disk flushes

Extras when you encrypt: biometrics (Face ID / Touch ID / Fingerprint — optional standalone ksafe-biometrics module) · root/jailbreak detection (WARN/BLOCK + analytics callback) · memory policy (RAM-exposure modes) · a one-line hardware-isolated DB passphrase for SQLCipher / SQLDelight / Room.

Demo & Videos

KSafe in action across many scenarios: KSafeDemo — Compose Multiplatform app.

Author's Video Philipp Lackner's Video Jimmy Plazas's Video
image image image
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

Table of Contents


Setup

Maven Central

1 - Add the Dependency

// commonMain or Android-only build.gradle(.kts)
implementation("eu.anifantakis:ksafe:2.1.1")
implementation("eu.anifantakis:ksafe-compose:2.1.1")     // ← Compose state (optional)
implementation("eu.anifantakis:ksafe-biometrics:2.1.1")  // ← Biometric auth (optional)

Skip ksafe-compose if you don't use Jetpack Compose or mutableStateOf persistence.

Skip ksafe-biometrics if you don't need Face ID / Touch ID / Fingerprint verification. The biometrics module is fully independent — it has no dependency on :ksafe and can be used on its own to protect any action in your app.

Note: kotlinx-serialization-json comes in transitively — don't add it yourself.

2 - Apply the kotlinx-serialization plugin

Required only if you store @Serializable data classes. Add it to libs.versions.toml:

[versions]
kotlin = "2.2.21"

[plugins]
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }

then apply it in build.gradle.kts:

plugins {
  //...
  alias(libs.plugins.kotlin.serialization)
}

3 - Instantiate

// Android
val ksafe = KSafe(context)

// iOS / macOS / JVM / WASM / JS
val ksafe = KSafe()

With Koin (recommended for KMP):

// Android
actual val platformModule = module {
    single { KSafe(androidApplication()) }
}

// iOS / macOS / JVM / WASM / JS
actual val platformModule = module {
    single { KSafe() }
}

Multi-instance setups, web awaitCacheReady() (wasmJs + js), full per-platform Koin examples, the custom storage directory option (baseDir on JVM/Android, directory on iOS / macOS — for example to align with $XDG_DATA_HOME, noBackupFilesDir, or a sandboxed Mac app's container), and the optional KSafe.close() for apps that re-create instances mid-process: docs/SETUP.md.

Basic Usage

A handful of examples cover 95% of real-world use. Full reference (Compose policy, cross-screen sync, write modes, nullables, deletion, full ViewModel): docs/USAGE.md.

// 1. Property delegate — synchronous, non-blocking, encrypted, persisted
var counter by ksafe(0)
counter++

// 2. Compose state on a ViewModel / class field — reactive UI + persistence (requires ksafe-compose)
var username by ksafe.mutableStateOf("Guest")

// 3. Compose state inside a @Composable body — the rememberSaveable analogue, but persists across app restarts
//    var currentTab by ksafe.rememberKSafeState(Tab.Home)   // key auto-resolves to "currentTab"; no ViewModel needed

// 4. Reactive flows — read-only StateFlow, read/write MutableStateFlow, or read/write Flow without a scope
val user: StateFlow<User> by ksafe.asStateFlow(User(), viewModelScope)         // read-only
private val _state by ksafe.asMutableStateFlow(MoviesState(), viewModelScope)  // read/write, hot
val state = _state.asStateFlow()
val themeMode: WritableKSafeFlow<ThemeMode> by ksafe.asWritableFlow(ThemeMode.DEVICE) // read/write, cold; set() to write

// 5. Suspend API — when you want to await the disk flush
viewModelScope.launch {
    ksafe.put("profile", user)
    val loaded: User = ksafe.get("profile", User())
}

// 6. Direct API — non-suspend, hot-cache reads, background-flushed writes (~1000x faster for bulk ops)
ksafe.putDirect("counter", 42)
val n = ksafe.getDirect("counter", 0)

Per-entry plain / encrypted toggle via KSafeWriteMode:

var theme by ksafe("light", mode = KSafeWriteMode.Plain)

ksafe.putDirect(
    "pin", pin,
    mode = KSafeWriteMode.Encrypted(
        protection = KSafeEncryptedProtection.HARDWARE_ISOLATED,
        requireUnlockedDevice = true
    )
)

Complex objects — just mark them @Serializable; JSON and encryption are automatic:

@Serializable
data class AuthInfo(val accessToken: String = "", val refreshToken: String = "")

var authInfo by ksafe(AuthInfo())
authInfo = authInfo.copy(accessToken = "newToken")

Note: The property delegate works with any KSafe instance — var x by myKsafe(default) makes myKsafe the storage backend. The bare var x by ksafe(default) form requires an in-scope ksafe (the conventional name, typically your default instance). See docs/SETUP.md for the multi-instance pattern.

Custom JSON Serialization

For third-party types you can't annotate (UUID, Instant, BigDecimal…), register a KSerializer via KSafeConfig(json = customJson) and use @Contextual fields at the call site. Full walkthrough: docs/SERIALIZATION.md.

Isolating an app's keys (Desktop / Web)

Android and iOS keystores are OS-sandboxed per app. The JVM/Desktop OS secret store (macOS Keychain / Linux Secret Service) is per-OS-user, shared by every process, and Web IndexedDB/localStorage is shared per browser origin — so two apps using the same fileName could collide on the same key. Set a stable, app-unique namespace:

val ksafe = KSafe(config = KSafeConfig(appNamespace = "com.example.myapp"))

Production desktop apps should set it explicitly. Only the key-store destination is namespaced — KSafe ≤ 2.0 data still migrates unchanged. See docs/USAGE.md.

Compose Desktop release builds — strongly recommend modules("jdk.unsupported")

For production Compose Desktop release distributables, add these to your nativeDistributions block — they give KSafe OS-backed key custody (Keychain / DPAPI / Secret Service):

compose.desktop {
    application {
        nativeDistributions {
            // OS-backed key custody: JNA + DataStore's protobuf need sun.misc.Unsafe (jlink trims it).
            // java.management → only for a non-default KSafeSecurityPolicy.
            modules("jdk.unsupported", "java.management")
        }
    }
}

Without it KSafe still persists (at a software key tier) and migrates your data forward when you add the module — the trade-off and the key-file risk are in docs/JVM_PROTECTION.md; KSafeDemo shows it live on its Security screen.


Cryptographic Utilities

Two small cross-platform helpers:

import eu.anifantakis.lib.ksafe.internal.secureRandomBytes

// Secure random bytes (SecureRandom / SecRandomCopyBytes / WebCrypto)
val nonce = secureRandomBytes(16)

// Generate-or-retrieve a hardware-isolated 256-bit secret (great for DB passphrases)
val passphrase = ksafe.getOrCreateSecret("main.db")

secureRandomBytes lives under eu.anifantakis.lib.ksafe.internal — it's the same primitive KSafe uses internally, exposed for app code that needs a CSPRNG.

Sizes, protection tiers, Room + SQLCipher / SQLDelight examples: docs/SECURITY.md#cryptographic-utilities.


Why use KSafe?

  • Hardware-backed security — AES-256-GCM, keys in Android Keystore / Apple Keychain (iOS + macOS) / JVM OS secret store (Windows DPAPI · macOS Keychain · Linux libsecret, software fallback) / non-extractable WebCrypto key in IndexedDB. Per-property control via KSafeWriteMode + KSafeEncryptedProtection tiers
  • Biometric auth — Face ID, Touch ID, Fingerprint, with auth caching
  • Root & jailbreak detection — configurable WARN/BLOCK actions
  • Clean reinstalls — automatic cleanup on fresh install
  • One code path — no expect/actual juggling; common code owns the vault
  • Ease of usevar launchCount by ksafe(0), that is literally it
  • Versatility — primitives, data classes, sealed hierarchies, lists, sets, nullables
  • Performance — zero-latency UI reads via hybrid hot cache
  • Desktop & Web — full JVM/Desktop, native macOS, and browser support on both Kotlin/WASM and Kotlin/JS alongside Android and iOS

How KSafe Compares

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/macOS/JVM/WASM/JS
Nullable support ❌ No ❌ No ✅ Primitives (*OrNull getters) ✅ Primitives ✅ Primitives + objects + delegates *
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 ✅ 4 policies (LAZY_PLAIN_TEXT / PLAIN_TEXT / ENCRYPTED / ENCRYPTED_WITH_TIMED_CACHE)
Hot cache ✅ Synchronized HashMap ❌ No (Flow only) ✅ Platform-native cache ❌ No ConcurrentHashMap + optimistic writes
Write batching ❌ No ❌ No ❌ No ❌ No ✅ 16ms coalescing

* Nullability flows uniformly through every API shape — primitives, @Serializable objects, and all delegate / Compose / Flow forms. null is a distinct, persisted state, not "missing." Full examples: docs/USAGE.md#nullable-values.


Performance Benchmarks

API Read Write Best For
getDirect/putDirect 0.002 ms 0.004 ms UI, hot cache, fire-and-forget
get/put (suspend) 0.021 ms 0.62 ms Must guarantee persistence; multiple concurrent callers

vs competitors (encrypted): ~21× faster reads than KVault and ~24× faster than EncryptedSharedPreferences; ~127× faster encrypted writes than KVault and ~14× faster than EncryptedSharedPreferences. Unencrypted writes are ~3× faster than MMKV and ~3× faster than SharedPreferences.

Numbers reflect the v2 envelope introduced in 2.0 (per-datastore master AES key cached in-process, eliminating per-entry Keystore IPC for non-isolated encrypted ops). Measured on an AOSP Emulator (API 37) running on a MacBook Pro (Apple Silicon). Suspend API benchmarks issue all iterations as concurrent coroutines (GlobalScope.launch + joinAll) — the natural usage pattern when multiple coroutines persist values in parallel. Real-world numbers depend on device, workload, and data size — see docs/BENCHMARKS.md for the methodology, full tables, cold-start numbers, and architecture notes.

Compatibility

Platform Minimum Version Notes
Android API 24 (Android 7.0) Hardware-backed Keystore on supported devices
iOS iOS 13+ Keychain-backed symmetric keys (protected by device passcode); Secure Enclave on real devices
macOS (native) macOS 11+ (macosArm64, macosX64) Same Keychain + CryptoKit path as iOS; Secure Enclave on Apple Silicon and T2-equipped Macs
JVM/Desktop JDK 11+ Key in OS secret store — Windows DPAPI / macOS Keychain / Linux Secret Service (libsecret); software fallback + warning when none is available
Kotlin/WASM (Browser) Browsers with WasmGC (Chrome 119+, Firefox 120+, Safari 18+) WebCrypto API; non-extractable key in IndexedDB, values in localStorage
Kotlin/JS (Browser) Any modern browser WebCrypto API; non-extractable key in IndexedDB, values in localStorage — use this for older browsers or pre-existing JS builds
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)

Advanced Topics


Biometric Authentication

A standalone biometric helper (Android + iOS + macOS) that can gate any action in your app — not just KSafe ops. Ships as the optional :ksafe-biometrics artifact and depends on nothing else from KSafe, so apps that need only biometric verification can use it on its own.

Static API. No instance, no DI wiring, no Context parameter. On Android the library auto-initializes via a ContentProvider declared in its merged manifest (the same pattern WorkManager / Firebase use), so consumers don't need to touch their Application class.

// Same call shape on every platform — Android, iOS, macOS, JVM, web.

// Callback-based
KSafeBiometrics.verifyBiometricDirect("Authenticate to increment") { success ->
    if (success) secureCounter++
}

// Suspend-based
if (KSafeBiometrics.verifyBiometric("Authenticate to increment")) {
    secureCounter++
}

Auth caching, scoped sessions, platform setup, complete examples: docs/BIOMETRICS.md.

Migrating from KSafe ≤1.x? Biometric methods used to live on KSafe itself. In 2.0 they moved to a separate module. Add implementation("eu.anifantakis:ksafe-biometrics:2.1.1"), change import eu.anifantakis.lib.ksafe.BiometricAuthorizationDurationimport eu.anifantakis.lib.ksafe.biometrics.BiometricAuthorizationDuration, replace ksafe.verifyBiometric(...) with KSafeBiometrics.verifyBiometric(...). Method names and signatures are unchanged. No instance to construct, no DI wiring needed.


Runtime Security Policy

Detect and respond to runtime threats — root/jailbreak, debugger, emulator, debug builds:

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}")
        }
    )
)

Preset policies, BLOCK exception handling, Compose stability, detection methods: docs/SECURITY.md.


Key Protection Diagnostics

Find out what key custody this KSafe instance actually got — including any silent fallback (e.g. JVM dropping from SANDBOX_PROTECTED to SOFTWARE when no OS vault is reachable):

val info = ksafe.protectionInfo
// info.intendedLevel  = SANDBOX_PROTECTED              // engine baseline
// info.effectiveLevel = SOFTWARE                       // vault self-test failed
// info.custody        = "DataStore (software, ...)"    // human-readable
// info.notes          = ["jvm_os_vault_unavailable"]   // stable code

// Gate startup, drive feature logic, or surface a UX banner
check(info.effectiveLevel >= KSafeProtectionLevel.SANDBOX_PROTECTED)

KSafeProtectionLevel is a universally-ordered scale — SOFTWARE < SANDBOX_PROTECTED < HARDWARE_BACKED < HARDWARE_ISOLATED. One ordinal comparison works across every platform. Per-platform truth table, runtime-decision patterns (gating, tighter re-auth windows, feature disablement, UX honesty banners, intended-vs-effective delta), and all defined notes codes: docs/PROTECTION_INFO.md.


Memory Security Policy

Trade off performance vs. security for data in RAM:

val ksafe = KSafe(
    fileName = "secrets",
    memoryPolicy = KSafeMemoryPolicy.LAZY_PLAIN_TEXT // Default
)
Policy Best For RAM Contents Read Cost Security
LAZY_PLAIN_TEXT (Default) General-purpose: settings, tokens, app state Ciphertext at rest; plaintext appears after first read of each key and stays First read decrypts, then O(1) forever Low (after first read) — same exposure as PLAIN_TEXT for keys you've actually touched
PLAIN_TEXT (discouraged) Apps that want decrypt failures surfaced synchronously at startup Plaintext (forever, eagerly decrypted at cold start) O(1) lookup Low — all data exposed in memory; cold start pays $O(n)$ Keystore round-trips up front
ENCRYPTED Tokens, passwords, financial data Ciphertext only AES-GCM decrypt every read High — nothing plaintext in RAM
ENCRYPTED_WITH_TIMED_CACHE Compose/SwiftUI screens accessing the same encrypted value many times per frame Ciphertext + short-lived plaintext (TTL) First read of a window decrypts, then O(1) for TTL Medium — plaintext only for recently-accessed keys, only for seconds

Timed cache details, constructor params, lock-state policies, multi-instance lock policies: docs/MEMORY.md.


Deep-Dive Documentation

Internals, advanced features, reference material:

Topic Description
KSafe Skill for AI agents Self-contained skill file teaching any agentskills.io-compatible agent (Claude Code, Codex, Gemini CLI, Copilot CLI, Junie, …) the patterns, anti-patterns, and gotchas for KSafe. Install instructions at the top of this README.
Complete Usage Guide Every API shape: delegates, flow delegates, Compose state, suspend/direct APIs, write modes, nullables, full ViewModel
Setup with Koin Multi-instance setups (prefs vs vault), web awaitCacheReady() (wasmJs + js), full platform examples, custom storage directory (baseDir / directory)
Custom JSON Serialization Registering KSerializers for UUID, Instant, and other third-party types
Performance Benchmarks Full benchmark tables, cold start numbers, architecture deep-dive
Biometric Authentication Authorization caching, scoped sessions, platform setup, complete examples
Security Runtime security policy, encryption internals, threat model, hardware isolation, key storage queries, crypto utilities
Protection Info Instance-level diagnostic API: KSafe.protectionInfo, the cross-platform KSafeProtectionLevel scale, per-platform truth table, consumer gating / telemetry / UI patterns
JVM Key Protection Deep dive on how the AES key is held on each JVM host: Windows DPAPI, macOS login Keychain, Linux Secret Service (libsecret), the software fallback, the opt-out, and the per-app namespace
Encryption Proof Per-platform automated proof tests + manual commands to inspect the raw stored bytes and see the ciphertext yourself
Memory Policy Timed cache, constructor parameters, encryption config, device lock-state policies
Architecture The conceptual model: three modules, three rings (public API / KSafeCore orchestrator / platform shells), hot cache + write coalescer, the KSafePlatformStorage and KSafeEncryption interfaces, memory policies, and how 2.0 consolidated ~5,900 lines of duplicated platform logic into ~890
Source-tree tour File-by-file walkthrough of every Kotlin source file in :ksafe: where each behaviour lives and why. Companion to the Architecture doc — Architecture is "the model," TOUR is "the map."
Testing Running tests, building iOS test app, test features
Migration Guide Upgrading from v1.x → v2.0 (biometric module extraction, iOS path migration), v1.6.x → v1.7.0 (encrypted: BooleanKSafeWriteMode), and v1.1.x → v1.2.0+
Alternatives & Comparison KSafe vs EncryptedSharedPrefs, KVault, SQLCipher, and more

Licence

Licensed under the Apache License 2.0 — see http://www.apache.org/licenses/LICENSE-2.0. Distributed "AS IS", without warranties of any kind.

About

A library for saving key/value pair data for Kotlin Multiplatform and Android. Encryption enabled by default, with option for Plain (unencrypted) storage. Supports Property Delegates, Flow/StateFlow, with Jetpack Compose and biometrics integration using hardware-backed encryption.

Topics

Resources

License

Security policy

Stars

Watchers

Forks

Packages

 
 
 

Contributors