--- name: ksafe description: | Required before any reply that touches KSafe (`by ksafe(...)`, `ksafe.get/put`, :ksafe-compose, :ksafe-biometrics), even a 'can KSafe do X?' question or a one-line change that looks like plain Kotlin. KSafe's signatures, defaults and overloads changed in recent releases, so answers from memory give wrong code. Typical: making one value unencrypted, passing a KSerializer for a generic T, faking KSafe in commonTest, compile errors, crashes, Desktop packaging. Also use when KSafe is unnamed but Kotlin/Compose Multiplatform code must keep secrets or settings on device: tokens, PINs, passwords, DB passphrases, encrypted prefs, a testable local data source, choosing or replacing a KMP secure-storage library (EncryptedSharedPreferences, DataStore, KVault, Multiplatform Settings), Face ID/fingerprint/Windows Hello gating, key rotation, proving keys sit in StrongBox/Secure Enclave/Keychain. Skip storage work with no KMP target and no KSafe (pure Swift, Android-only, browser). --- # KSafe — Kotlin Multiplatform Encrypted Persistence You are about to write or modify code that uses **KSafe**: a one-API encrypted key-value store covering Android, iOS, native macOS, JVM Desktop, Kotlin/WasmJS, and Kotlin/JS. Encrypted values use AES-GCM. Keep **payload encryption**, **durable key custody**, and the **working key in process memory** conceptually separate: the secure paths protect a long-lived key or KEK in a platform vault, while documented software fallbacks can keep key material in a permission-protected file. Use `protectionInfo` to report the route that was actually achieved. This skill is self-contained — it covers everything you need to **set up** and **use** KSafe correctly. Always prefer the **property delegate** as the default API. **The single most important fact: KSafe is encrypted by default.** `ksafe(value)` encrypts. You opt *out* for non-secret values with `mode = KSafeWriteMode.Plain`. --- ## Key-custody matrix (this is what makes KSafe interesting) | Platform | Default encrypted route | `HARDWARE_ISOLATED` upgrade / fallback | |---|---|---| | Android | Relaxed: non-exportable Keystore KEK wraps a DEK stored as ciphertext; the unwrapped DEK is cached in RAM. Strict unlock mode performs payload operations in Keystore. | Per-entry StrongBox when available; normal Keystore fallback otherwise. | | iOS / native macOS | AES key stored in Keychain, loaded into the app for CryptoKit payload operations. | Secure Enclave EC key wraps a per-entry AES DEK; ordinary Keychain fallback otherwise. Simulator-only entitlement failure can use a reported software file fallback. | | JVM Desktop | AES key protected by Windows DPAPI, macOS login Keychain, or Linux Secret Service, then loaded for JCE payload operations. | No stronger common tier. A reported permission-protected file fallback is used only where no usable OS vault exists or the user explicitly opts out. | | WasmJS / JS | Non-extractable WebCrypto AES `CryptoKey` in IndexedDB. | No stronger tier; outside a secure context encrypted operations are non-operational rather than silently written plain. | When a stronger tier is absent (for example no StrongBox, no Secure Enclave, or no supported desktop OS vault), KSafe **degrades to the documented next-best path and reports the degrade** through `KSafe.protectionInfo`. If a real JVM OS vault exists but is temporarily unreachable, KSafe fails closed instead of inventing a replacement software key. Never trade operability for silent data loss. --- # SETUP ## Dependencies ```kotlin // commonMain (or Android-only) build.gradle.kts implementation("eu.anifantakis:ksafe:") // core implementation("eu.anifantakis:ksafe-compose:") // optional: Compose state implementation("eu.anifantakis:ksafe-biometrics:") // optional: biometric prompts ``` `kotlinx-serialization-json` comes transitively — don't add it yourself. If you store `@Serializable` classes, apply the kotlin-serialization plugin in your app. ## Construction ```kotlin // Android — pass applicationContext (NOT an Activity context — it leaks) val ksafe = KSafe(applicationContext) // iOS / macOS / JVM / WasmJS / JS — no context val ksafe = KSafe() val ksafe = KSafe(fileName = "auth") // isolated named instance ``` Full factory parameters (all platforms except where noted): ```kotlin KSafe( context: Context, // Android ONLY — applicationContext fileName: String? = null, // null = default instance; else isolates storage lazyLoad: Boolean = false, // ignored on web memoryPolicy: KSafeMemoryPolicy = KSafeMemoryPolicy.LAZY_PLAIN_TEXT, config: KSafeConfig = KSafeConfig(), securityPolicy: KSafeSecurityPolicy = KSafeSecurityPolicy.Default, baseDir: File? = null, // JVM/Android custom dir; iOS uses `directory: String?` ) KSafeConfig( aesKeySize: KSafeAesKeySize = KSafeAesKeySize.BITS_256, // BITS_128 or BITS_256; every platform requireUnlockedDevice: Boolean = false, // default unlock policy for encrypted writes json: Json = KSafeDefaults.json, // custom serialization appNamespace: String? = null, // multi-app isolation (see below) keyRotationPolicy: KSafeKeyRotationPolicy = KSafeKeyRotationPolicy.Never, // see Key rotation keyRotationRetryAttempts: Int = 3, // next-instance retries for skipped work; 0 = off ) ``` ## Recommended DI setup (Koin) — mode-typed views over ONE instance (3.1.0+) Encryption adds per-value overhead (AES-GCM + JSON envelope; ~µs since 2.1.2, but never free). For non-secret data — theme, last screen, UI flags — that overhead is wasted. Since 3.1.0 the recommended pattern is **one store, typed views**: `KSafePlain` / `KSafeEncrypted` / `KSafeHardwareIsolated` wrap an existing instance and freeze the write mode at the type level, so no call site carries a `mode =` argument the author can forget — and the compiler replaces the stringly `named(...)` qualifiers. ```kotlin // commonMain expect val platformModule: Module // androidMain actual val platformModule = module { single { KSafe(context = androidApplication(), fileName = "app") } single { KSafePlain(get()) } single { KSafeHardwareIsolated(get()) } } // iosMain / jvmMain / wasmJsMain / jsMain (no context) actual val platformModule = module { single { KSafe(fileName = "app") } single { KSafePlain(get()) } single { KSafeHardwareIsolated(get()) } } ``` ```kotlin class MyViewModel( private val prefs: KSafePlain, // every write is Plain — by TYPE private val vault: KSafeHardwareIsolated, // every write requests SE/StrongBox — by TYPE ) : ViewModel() { var theme by prefs("dark") // no mode argument exists to get wrong var lastScreen by prefs("home") var authToken by vault("") var userPin by vault("") } ``` All views share the one store (same file, key namespace, cache — and ONE `awaitCacheReady()` on web). Rules an agent must know: - The guarantee is **write-side only**: reads are mode-free and auto-detect each entry's protection, so `prefs.get()` reads an encrypted entry fine. - The views cover the FULL write surface: `put`/`putDirect`, the `by view(...)` delegate (3.2.0+: its result is a `KSafeReference`, also a no-`by` `.value` handle when given an explicit key — see *Direct handle* under USAGE), `asFlow`/`asWritableFlow`/`asStateFlow`/`asMutableStateFlow`/`getStateFlow`, and (via `:ksafe-compose`) `mutableStateOf`/`rememberKSafeState`. - Store-scoped operations (`rotateKeys`, `clearAll`, `close`, `protectionInfo`, `getKeyInfo`, `awaitCacheReady`, `getOrCreateSecret`) are NOT on the views — call them on `view.ksafe`. - `KSafeEncrypted(ksafe, requireUnlockedDevice = true)` freezes a strict unlock policy for everything written through that view; the default constructor inherits `KSafe.defaultWriteMode`. - Quick local views without DI: `ksafe.plain` / `ksafe.encrypted` / `ksafe.hardwareIsolated`. Pre-3.1.0 (or when you genuinely want separate files): the older two-instance pattern with `named("prefs")` / `named("vault")` qualifiers and explicit `mode = KSafeWriteMode.Plain` per declaration still works — but it is convention, not a compiler guarantee. If your app only stores secrets, a **single default instance** is fine: ```kotlin actual val platformModule = module { single { KSafe(/* androidApplication() on Android */) } } ``` ## Multiple instances — the rules - **Each `KSafe(fileName=...)` should be a singleton.** Create once (via DI), reuse everywhere. - Two live instances on the same `fileName` are **safe on Android / iOS / macOS / JVM**: since 2.1.2 they share one ref-counted backend (only the last `close()` tears it down), since 3.0.0 a per-store commit lock serializes their commits, rotation and key sweeps, and since 3.2.0 they share one storage layer, so a write, `clearAll()` or `rotateKeys()` through one instance is seen by every other live instance on that file. Still wasteful, and still **broken on web** (per-instance caches diverge). Keep the singleton pattern. - **One process only.** KSafe wires a single-process DataStore coordinator plus its own process-local cache/write queue. DataStore itself has multi-process APIs, but KSafe does not use them — never touch the same `fileName` from a second process (widget, foreground service, push process). Give other processes their own `fileName`. - **`fileName` must match `[a-z][a-z0-9_]*`** — start lowercase, then lowercase/digits/underscores. Valid: `"userdata"`, `"settings"`, `"data_v2"`. Invalid: spaces, dots, slashes, hyphens, uppercase. - **Key names: two reserved patterns are rejected on write (3.0.0+)** with `IllegalArgumentException`: keys starting with `__ksafe_` or `encrypted_`, and keys whose trailing segment — after a `.` or a `:`, the two ways the platforms join an alias — spells one of KSafe's alias sentinels: `__ksafe_master__` / `__ksafe_master_locked__` (optionally `.gN`), `__ksafe_strict__` / `__ksafe_gen__` (optionally `.h`), or the JVM vault markers `__ksafe_nsdel__` / `__ksafe_swfb__`. Simple rule: never end a key with a `__ksafe_…__` segment. Fail-fast on `put`/`putDirect`/`delete`/`deleteDirect`, delegate assignment, Compose state, and flow writes; **reads are unaffected**. `"user_encrypted_flag"` is fine — the prefix must match exactly. ```kotlin // ✅ Good — singletons via DI val appModule = module { single { KSafe() } // default single(named("user")) { KSafe(fileName = "userdata") } } // ❌ Bad — two instances, same file class ScreenA { val prefs = KSafe(fileName = "userdata") } class ScreenB { val prefs = KSafe(fileName = "userdata") } // DON'T ``` ## Custom storage directory (optional) Defaults are platform-appropriate (Android app sandbox, iOS `NSApplicationSupportDirectory`, JVM `~/.eu_anifantakis_ksafe/` at `0700`, web `localStorage`). Override only when needed: ```kotlin // JVM — e.g. align with XDG val ksafe = KSafe(fileName = "vault", baseDir = File("$xdgDataHome/myapp/ksafe")) // Android — e.g. no-backup dir val ksafe = KSafe(context = context, fileName = "vault", baseDir = File(context.noBackupFilesDir, "ksafe")) // iOS — absolute path string (note: `directory`, not `baseDir`) val ksafe = KSafe(fileName = "vault", directory = "/path/to/dir") ``` Web has no directory concept (no `baseDir`). Don't point `baseDir` at external storage for sensitive data on Android. ## `KSafe.close()` — only when re-creating instances mid-process The app-lifetime singleton never needs disposal (the OS reclaims everything at exit). `close()` exists for account/profile switching that changes `fileName`, long-running JVM services building per-session instances, or dev-time hot-reload. It cancels background coroutines and releases the DataStore scope/file handle — ref-counted since 2.1.2, so closing one instance never breaks another still using the same `fileName`. Idempotent; after `close()` discard the instance — suspend calls on a closed instance can suspend indefinitely rather than fail fast. Quiesce your own writers first: `close()` cancels the awaiters of writes already queued when it runs, but a suspending write racing `close()` from another coroutine can slip past that one-shot drain and never complete — await your in-flight writes before closing. ## Web ONLY — `awaitCacheReady()` WebCrypto is async-only, so on WasmJS/JS KSafe must finish decrypting its cache before the first synchronous read of an encrypted key. Call `awaitCacheReady()` once at startup. **No-op on Android/iOS/macOS/JVM.** Placement depends on how you start Koin: ```kotlin // startKoin (classic) — Koin is up before ComposeViewport, getKoin() works immediately fun main() { startKoin { modules(sharedModule, platformModule) } ComposeViewport(document.body!!) { var ready by remember { mutableStateOf(false) } LaunchedEffect(Unit) { getKoin().get().awaitCacheReady(); ready = true } if (ready) App() } } // KoinMultiplatformApplication (Compose) — awaitCacheReady must go INSIDE the composable fun main() { ComposeViewport(document.body!!) { KoinMultiplatformApplication(config = createKoinConfiguration()) { var ready by remember { mutableStateOf(false) } LaunchedEffect(Unit) { getKoin().get().awaitCacheReady(); ready = true } if (ready) AppContent() } } } ``` In a **multiplatform app**, `awaitCacheReady()` cannot be called from common code — the extension is declared only on web source sets. Wrap it in an expect/actual barrier so shared startup code awaits it with no platform guard (pass EVERY app-lifetime KSafe instance): ```kotlin // commonMain expect suspend fun awaitKSafeCachesReady(vararg stores: KSafe) // webMain (or identical jsMain + wasmJsMain files if you have no webMain source set) actual suspend fun awaitKSafeCachesReady(vararg stores: KSafe) { stores.forEach { it.awaitCacheReady() } } // androidMain / appleMain / jvmMain — reads are synchronous once the instance exists actual suspend fun awaitKSafeCachesReady(vararg stores: KSafe) = Unit ``` --- # USAGE ## Property delegate — the 80% case (encrypted by default) Default value is the **first positional argument** (there is no `default =` or `encrypted =` named parameter — `encrypted` is a deprecated legacy param, never generate it). Storage key defaults to the property name unless you pass `key`. ```kotlin class AuthViewModel(private val ksafe: KSafe) : ViewModel() { var authToken by ksafe("") // encrypted (default) var userId by ksafe(0L, mode = KSafeWriteMode.Plain) // opt OUT of encryption var lastSync by ksafe(Instant.EPOCH) // any @Serializable type var theme by ksafe(ThemeMode.DEVICE, key = "theme", mode = KSafeWriteMode.Plain) init { authToken = "..." } // just assign — reads sync from hot cache, writes coalesce } ``` What you get: synchronous reads from an in-memory hot cache (~µs), coalesced background writes (multiple writes within a 16ms window land in one transaction, never blocks the caller), and reactivity (see Flows below). The delegate works on **any** `KSafe` instance — `var x by myKsafe(value)` makes `myKsafe` the backing store. ## Direct handle — `ksafe(default, key)` without `by` (3.2.0+) The same call, given an explicit `key`, returns a `KSafeReference` you can hold in a `val`, hand to a class, and read or write through `.value`. Reads come from the same hot cache as the delegate; writes are fire-and-forget with the `KSafeWriteMode` captured when the handle was created. The mode views' `invoke` returns the same handle with their frozen mode (`ksafe.plain(0, key = "theme")`, `vault("", key = "pin")`). Delegate and handle share one store and one cache — mix them freely. ```kotlin class SessionRepository(ksafe: KSafe) { private val token = ksafe("", key = "auth_token") // KSafeReference, encrypted private val launches = ksafe.plain(0, key = "launches") // Plain, frozen by the view fun onLogin(t: String) { token.value = t; launches.value++ } fun isLoggedIn() = token.value.isNotEmpty() } ``` Three rules: - **`.value` needs an explicit `key`.** A plain `=` carries no property name, so a key-less handle is delegate-only and `.value` on it throws `IllegalStateException`. - **Never read `.value` inside a composable.** No snapshot state sits behind the handle, so the composable will not recompose when the value changes. Use `:ksafe-compose` (`rememberKSafeState` / `mutableStateOf`) or `asStateFlow().collectAsState()` instead. - **The deprecated `encrypted: Boolean` overload still returns the delegate type.** Never generate it (see above); if you meet one, move it to `mode` before using `.value`. ## Storing complex objects ```kotlin @Serializable data class AuthInfo(val accessToken: String = "", val refreshToken: String = "", val expiresIn: Long = 0L) var authInfo by ksafe(AuthInfo()) // encryption + JSON automatically authInfo = authInfo.copy(accessToken = "newToken") ``` "Serializer for class X is not found"? Add `@Serializable` and the serialization plugin. ## Nullable values — the reified-`null` trap (IMPORTANT) KSafe supports nullable types, and `null` is preserved as a distinct state (not "missing"). **But never pass a bare `null` as the default value** — reified generics have nothing to infer `T` from, so `T` collapses to `Nothing?` and the call always returns `null` even when a value is stored. ```kotlin // ❌ Wrong — always returns null, ignores stored value val token = ksafe.get("auth_token", null) var token by ksafe(null) // ✅ Correct — explicit type parameter val token = ksafe.get("auth_token", null) var token by ksafe(null) // ✅ Correct — typed declaration drives inference val token: String? = ksafe.get("auth_token", null) var token: String? by ksafe(null) ``` ## Suspend vs Direct API ```kotlin // Suspend — awaits the disk commit. Use when persistence is a precondition // for the next step (token refresh, payment confirmation). Concurrent callers // get coalesced, so individual latency drops under load. suspend fun save() { ksafe.put("profile", userProfile) val cached: User = ksafe.get("profile", User()) } // Direct — fire-and-forget (queue + return). Use for UI/hot-cache writes where // you don't need to know the disk write landed. ksafe.putDirect("counter", 42) val n = ksafe.getDirect("counter", 0) ``` Signature order is **key first, then defaultValue**: `get(key, defaultValue)`, `getDirect(key, defaultValue)`. ## Repository data source — KSafe behind your own interface (3.3.0+) When you create a repository or a local data source that persists values with KSafe, propose this shape by default: the repository depends on one small app-owned interface, not on `KSafe`. One interface serves every repository, one KSafe-backed class implements it, and one map-backed fake replaces it in plain `commonTest` unit tests (no device, no Keystore, no Android `Context`). It rests on the explicit-serializer overloads (3.3.0+): every typed call (`get`, `getDirect`, `getFlow`, `getStateFlow`, `put`, `putDirect`) also takes a `KSerializer` right after the value. The reified calls cannot be used here — a generic interface method has no reified `T` ("Cannot use 'T' as reified type parameter"). ```kotlin // Data layer: the only storage type repositories see. interface SecureStore { suspend fun get(key: String, defaultValue: T, serializer: KSerializer): T suspend fun put(key: String, value: T, serializer: KSerializer) fun observe(key: String, defaultValue: T, serializer: KSerializer): Flow suspend fun delete(key: String) } // Short call sites: store.get("theme", "light"), store.put("ids", ids), store.observe("info", Info()). suspend inline fun SecureStore.get(key: String, defaultValue: T): T = get(key, defaultValue, serializer()) suspend inline fun SecureStore.put(key: String, value: T) = put(key, value, serializer()) inline fun SecureStore.observe(key: String, defaultValue: T): Flow = observe(key, defaultValue, serializer()) // Production: the only class that touches KSafe. The write mode is fixed per instance. class KSafeSecureStore( private val ksafe: KSafe, private val mode: KSafeWriteMode = ksafe.defaultWriteMode, ) : SecureStore { override suspend fun get(key: String, defaultValue: T, serializer: KSerializer): T = ksafe.get(key, defaultValue, serializer) override suspend fun put(key: String, value: T, serializer: KSerializer) = ksafe.put(key, value, serializer, mode) override fun observe(key: String, defaultValue: T, serializer: KSerializer): Flow = ksafe.getFlow(key, defaultValue, serializer) override suspend fun delete(key: String) = ksafe.delete(key) } // commonTest: one fake for every repository. class FakeSecureStore : SecureStore { private val values = MutableStateFlow>(emptyMap()) @Suppress("UNCHECKED_CAST") override suspend fun get(key: String, defaultValue: T, serializer: KSerializer): T = if (key in values.value) values.value[key] as T else defaultValue override suspend fun put(key: String, value: T, serializer: KSerializer) = values.update { it + (key to value) } @Suppress("UNCHECKED_CAST") override fun observe(key: String, defaultValue: T, serializer: KSerializer): Flow = values.map { if (key in it) it[key] as T else defaultValue }.distinctUntilChanged() override suspend fun delete(key: String) = values.update { it - key } } // A repository depends on the interface only. class AuthRepository(private val store: SecureStore) { suspend fun accessToken(): String? = store.get("access_token", null) suspend fun saveAccessToken(token: String?) = store.put("access_token", token) val authInfo: Flow = store.observe("auth_info", AuthInfo()) } // Koin: one store per sensitivity, still one KSafe file. single { KSafeSecureStore(get()) } // encrypted single(named("prefs")) { KSafeSecureStore(get(), KSafeWriteMode.Plain) } // non-secret ``` Rules for this pattern: - A non-suspend interface (`fun get(...)`) is implemented with `getDirect` / `putDirect`; a suspend one with `get` / `put`. - There is no `KClass` overload. Pass a `KSerializer`: `User.serializer()`, `ListSerializer(User.serializer())`, or `serializer()` inside a reified function. - A nullable `T` needs a nullable serializer (`String.serializer().nullable`); the reified extension builds it when the type is `String?`. The serializer, not the default, decides whether a stored `null` reads back as `null`. - Both forms share one store: an entry written with a serializer reads back through the reified calls, and the other way round. - Mode views and delegates have no serializer overload. Fix the write mode in the store instance (the `mode` parameter above) instead. - A feature-specific data source with concrete types (reified calls inside its implementation) also works and needs no serializer. Prefer the shared `SecureStore` once more than one repository persists data: one fake then covers all of them. ## Write modes The delegate / `mutableStateOf` / `put` all default to encrypted. Use `mode` for control. **The `protection` param of `KSafeWriteMode.Encrypted` is `KSafeEncryptedProtection`** (write-side), NOT `KSafeProtection` (the read-side enum from `getKeyInfo`). Using `KSafeProtection` here will not compile. ```kotlin // Per-entry unlock policy (Apple) — inaccessible until first unlock since boot ksafe.put("token", value, mode = KSafeWriteMode.Encrypted( protection = KSafeEncryptedProtection.DEFAULT, requireUnlockedDevice = true, )) // HARDWARE_ISOLATED — StrongBox (Android) / Secure Enclave (Apple). Slower, // per-key Keystore allocation, needs hardware. Reserve for master passphrases / // identity keys; do NOT use as a default. ksafe.put("master_passphrase", value, mode = KSafeWriteMode.Encrypted( protection = KSafeEncryptedProtection.HARDWARE_ISOLATED, )) // Explicit plaintext ksafe.putDirect("theme", "dark", mode = KSafeWriteMode.Plain) ``` No-mode writes use encrypted defaults and pick up `KSafeConfig.requireUnlockedDevice`. Tightening an existing `HARDWARE_ISOLATED` entry's unlock policy (rewriting it with `requireUnlockedDevice = true` over a relaxed entry) takes effect at that write (3.0.0+), and it is copy-on-write: Android mints the strict Keystore key under a fresh internal alias and the relaxed key is reclaimed only after the rewrite commits — a locked device, a Keystore outage, or a crash mid-tighten just fails the write (retry later); the previous value stays readable. On Apple a tighten that can't be applied **fails the write** instead of leaving the item looser than declared — prefer suspend `put` for a policy tighten so a failure surfaces. Loosening back to relaxed is best-effort and never fails a write. ## Deleting ```kotlin ksafe.delete("profile") // suspend ksafe.deleteDirect("profile") // fire-and-forget ksafe.clearAll() // suspend — wipes everything (data + keys). Destructive. ``` Deleting removes both the value and its encryption key. ## Reactive reads — Flows and StateFlows All four are property delegates with `defaultValue` first. All auto-update on writes from anywhere (another screen, background sync, another delegate on the same key). `asFlow` / `asStateFlow` are **read-only** (writes go through `put`/`putDirect`); `asWritableFlow` / `asMutableStateFlow` are writable. The explicit-key twins are `getFlow(key, default)` (cold) and `getStateFlow(key, default, scope)` (hot); there are no writable twins, since writes go through `put`/`putDirect` anyway. Same types, but **only the delegates cache**: every `getStateFlow` call starts another watcher — see ANTI-patterns. ```kotlin class Repo(private val ksafe: KSafe) { // Cold Flow — read-only. Encrypted by default; pass mode = Plain to opt out. val username: Flow by ksafe.asFlow("Guest") val theme: Flow by ksafe.asFlow("light", key = "app_theme") // Writable cold Flow — set() persists, no CoroutineScope needed. val themeMode: WritableKSafeFlow by ksafe.asWritableFlow(ThemeMode.DEVICE) fun setTheme(m: ThemeMode) = themeMode.set(m) } class VM(private val ksafe: KSafe) : ViewModel() { // Hot StateFlow — read-only. Needs a scope. val username: StateFlow by ksafe.asStateFlow("Guest", viewModelScope) // Hot MutableStateFlow — .value = / .update {} persist automatically. // Drop-in for the standard MutableStateFlow pattern, but persisted + reactive. // Once you write through it, your value wins over stale storage echoes (2.1.2+). private val _state by ksafe.asMutableStateFlow(MoviesState(), viewModelScope) val state = _state.asStateFlow() fun load() { _state.update { it.copy(loading = true) } } } // Direct (non-delegate) form also exists: ksafe.getFlow(key, defaultValue).collect { … } ``` Or collect a delegate's flow in Compose: `val name by repo.username.collectAsState()`. ## Compose state — `:ksafe-compose` Two APIs with **deliberately different default modes**: ```kotlin // mutableStateOf — ENCRYPTED by default. For class fields (ViewModel/repository): // created once, lives for the class lifetime. class CounterViewModel(private val ksafe: KSafe) : ViewModel() { var pin by ksafe.mutableStateOf("") // encrypted (default) var counter by ksafe.mutableStateOf(0, mode = KSafeWriteMode.Plain) // opt out // Optional `scope` = live cross-screen sync (auto-updates when ANY writer changes // the key). Without scope: reads once at init, writes persist, but no live sync. // Since 2.1.2: once you write THROUGH a live state, your value is authoritative — // external emissions no longer revert in-flight edits (a pure-observer state the // user never writes still live-updates as before). var username by ksafe.mutableStateOf("Guest", scope = viewModelScope) } // rememberKSafeState — PLAIN by default (UI ephemera rarely needs encryption). // For composable-BODY state (no ViewModel). It's an EXTENSION on ksafe, default value // first. remember-scoped, so it survives recomposition AND process death. @Composable fun TabbedScreen(ksafe: KSafe) { var currentTab by ksafe.rememberKSafeState(0) // key = "currentTab" var draft by ksafe.rememberKSafeState("", key = "screen.draft") // explicit key var pin by ksafe.rememberKSafeState("", mode = KSafeWriteMode.Encrypted()) // opt IN // Live cross-screen sync: var theme by ksafe.rememberKSafeState(ThemeMode.LIGHT, key = "theme", observeExternalChanges = true) } ``` Rule of thumb: ViewModel/class property → `mutableStateOf`. Composable-body local state (tab index, scroll position, draft text, expanded sections) → `rememberKSafeState`. Domain data shared across screens stays in a ViewModel with `mutableStateOf`. --- ## Memory policy (construction-time tuning) `KSafe(memoryPolicy = …)` controls how the in-RAM cache holds values. Default is **`LAZY_PLAIN_TEXT`** — leave it unless you have a specific reason. | Policy | Behaviour | |---|---| | `LAZY_PLAIN_TEXT` (default) | First read of a key decrypts on demand, then caches plaintext permanently. Cold start does no bulk decrypt; steady-state reads are O(1). Best general choice. | | `ENCRYPTED` | Ciphertext stays in RAM; every read decrypts. Lowest plaintext-in-RAM exposure. | | `ENCRYPTED_WITH_TIMED_CACHE` | Like `ENCRYPTED`, but decrypted plaintext is side-cached for a TTL window. | | `PLAIN_TEXT` | Eagerly decrypts everything at startup. Discouraged — pays full cold-start cost; same RAM exposure as `LAZY_PLAIN_TEXT` without the lazy benefit. | Since 2.1.2, `ENCRYPTED` reads are pure-CPU AES on **every** platform (~µs): Android uses a TEE-wrapped data-encryption key unwrapped once into memory, matching what Apple/JVM always did. `ENCRYPTED` is now a realistic default for security-sensitive apps, not a 100× Android penalty. (`HARDWARE_ISOLATED` entries and a `requireUnlockedDevice` master still decrypt inside the TEE on every op — that's the point of those tiers.) Web forces `PLAIN_TEXT` internally (WebCrypto async-only) — hence `awaitCacheReady()`. --- ## Biometric-gated actions — `:ksafe-biometrics` (independent, static API) Independent of `:ksafe` — call directly for any biometric prompt. No DI, no `Context`, no init. Android auto-inits via `ContentProvider` (no `Application` changes); requires `AppCompatActivity`. ```kotlin // Callback variant — works anywhere KSafeBiometrics.verifyBiometricDirect("Unlock balance") { success -> if (success) showBalance() } // Suspend variant viewModelScope.launch { if (KSafeBiometrics.verifyBiometric("Confirm transaction")) proceed() } // Avoid re-prompts within a window. duration MUST be > 0 — a duration <= 0 is the // opt-out and never caches (enforced since 2.1.2). scope = null is the global session, // distinct from every named scope (including ""). The window counts real elapsed time, // including device sleep. KSafeBiometrics.verifyBiometric( reason = "Reauth", authorizationDuration = BiometricAuthorizationDuration(duration = 60_000L, scope = "MyScope"), ) // Hard biometric-only (no PIN/password/Apple-Watch fallback) KSafeBiometrics.verifyBiometric("Step-up", allowDeviceCredentialFallback = false) ``` Know up front whether a real prompt is even possible (2.2.1+) — `false` means verify would pass through / refuse without gating, so route to your own PIN/password flow instead: ```kotlin // suspend — never shows UI, no gesture needed. Probe ONCE at startup (on web: next to // awaitCacheReady()) and keep the result in app state for synchronous `if (available)` use. // On Android probe from a composition/Activity, NOT Application.onCreate — no FragmentActivity // host exists that early, so the cached answer is a permanent false. verifyBiometric waits for // the host; this probe does not. if (KSafeBiometrics.biometricsAvailable()) { /* biometric flow */ } else { /* PIN screen */ } // callback twin (non-suspending) — for a non-coroutine call site KSafeBiometrics.biometricsAvailableDirect { available -> if (available) showUnlock() else showPin() } ``` `verifyBiometric` is `suspend`; `verifyBiometricDirect` is callback-based and delivers `onResult` on the **main thread** on Android and Apple (2.1.2+) — safe to touch UI from it. Concurrent calls are serialized on **every** platform (Apple since 3.1.0): a second prompt queues behind the first and skips entirely if the holder just authorized the same scope. Sequential calls never prompt twice inside the window regardless. On Android a queued caller whose host Activity stopped while it waited (e.g. a Home press) returns `false` instead of hanging (3.2.0+). Prompt text comes from three process-wide defaults set once at startup, with per-call overrides (`title`/`cancelLabel` are appended AFTER the existing params): ```kotlin KSafeBiometrics.defaultTitle = "My App" // Android prompt title + WEB PASSKEY NAME KSafeBiometrics.defaultReason = "Unlock to continue" // Android subtitle / Apple localizedReason / Hello message KSafeBiometrics.defaultCancelLabel = null // null = the platform's LOCALIZED default — leave it null ``` `title` names the web passkey (`rp.name`/`user.name`/`displayName`) and is written **once at registration** — set it before the first `verifyBiometric()`. Apple/JVM have no title, ignore it. Renaming later: re-enroll ONCE via the introspection, never unconditionally — `if (KSafeBiometricsWeb.isRegistered && KSafeBiometricsWeb.registeredTitle != KSafeBiometrics.defaultTitle) KSafeBiometricsWeb.resetRegistration()` (both reflect KSafe's local record, not the authenticator's real state). `clearBiometricAuth(scope = null)` invalidates the cached authorization — all scopes, or one named scope — so the next gated action re-prompts; call it on logout / app-lock. It also revokes a prompt already on screen (3.0.0+): that caller still gets its `true`, but the success no longer seeds the prompt-free window. **Where a real prompt shows vs. pass-through** — `verifyBiometric` does NOT gate on every platform: | Platform | Real prompt | Biometrics unavailable | |---|---|---| | Android | BiometricPrompt | `false` | | iOS / native macOS | `LAContext` | `false` | | JVM macOS (2.2.1+) | `LocalAuthentication` (policy maps like native macOS) | strict + no Touch ID, or the bridge fails to load → `false` | | JVM Windows (2.2.1+) | Windows Hello (`UserConsentVerifier`) | strict + Hello not-configured, or the bridge fails to load → `false` | | JS / WasmJS (2.2.1+) | WebAuthn platform authenticator (Touch ID / Hello / fingerprint) | permissive `true` / strict `false` | | **JVM Linux** | none (no portable API) | **always `true`** (pass-through) | Opt-outs restore the legacy always-`true` no-op: `-Dksafe.biometrics.jvm.prompts=off` (JVM desktop), `KSafeBiometricsWeb.promptsEnabled = false` (web). Web specifics: first successful call enrolls a passkey (that ceremony verifies the user); the `reason` string is NOT shown (browser-controlled dialog); call from a user gesture or the browser may reject; needs a secure context (HTTPS/localhost); `KSafeBiometricsWeb.resetRegistration()` re-enrolls after an OS-side passkey removal (3.0.0+: also revokes every cached auth window, so the next call is a fresh ceremony, never a cache hit). Footguns: (1) on Windows — and on the web where the platform treats the PIN as part of Hello — `allowDeviceCredentialFallback = false` can't exclude the PIN; it still keys the auth cache strictly. (2) **JVM Linux always returns `true`** (no prompt API) — never rely on `verifyBiometric` as your ONLY security boundary there; gate it yourself. --- ## Database passphrase `getOrCreateSecret` is a **`suspend`** extension — call from a coroutine. ```kotlin suspend fun openDatabase(): AppDatabase { val passphrase: ByteArray = ksafe.getOrCreateSecret("main.db") // 256-bit, hw-isolated, idempotent return Room.databaseBuilder(context, AppDatabase::class.java, "main.db") .openHelperFactory(SupportFactory(passphrase)) .build() } ``` Params: `getOrCreateSecret(key, size = 32, protection = KSafeEncryptedProtection.HARDWARE_ISOLATED, requireUnlockedDevice = false)`. Works the same for SQLDelight + SQLCipher. **It never silently rotates.** If a secret exists on disk but can't be decrypted *right now* (locked device at cold start, OS key vault momentarily unreachable), it **throws** instead of minting a replacement — a rotated secret would permanently orphan the database it keys. Catch and retry after unlock; don't catch-and-regenerate yourself. --- ## Key rotation (3.0.0+) Re-encrypt every encrypted entry under fresh key material — values never change, nothing migrates, works on every platform: ```kotlin val r = ksafe.rotateKeys() // suspend; KSafeRotationResult(rotated, skipped, failed, keyGeneration) ``` Or declaratively (checked once per startup, runs in the background, never blocks): ```kotlin KSafe(config = KSafeConfig(keyRotationPolicy = KSafeKeyRotationPolicy.MaxAge(90.days))) ``` Rules an agent must know: - **Whole-store, not per-key.** `rotateKeys()` re-keys the ENTIRE store — there is no per-key rotate overload (the `DEFAULT` tier shares one master key per store), so re-keying one value means rotating everything. - **Needs an operational backend.** Rotation mints a new key generation, so it only works where `protectionInfo.isEncryptionOperational` is `true`; on a non-secure web page or a JVM whose OS vault is unreachable there is no fresh key to rotate to (entries come back `skipped` / `failed`). Preflight if unsure. - **Default is `Never`** — no NEW generation starts unless the app opts in. Recommend `MaxAge` only for compliance-type asks (PCI/SOC2 "rotate data-at-rest keys"); keys don't expire otherwise. Since 3.1.0, `Never` does not disable recovery: a generation carrying the explicit `r:1` lifecycle state resumes automatically on the next KSafe instance, and normally skipped work marked with a bounded `rp:N` budget retries at the same generation, at most once per next instance (3 attempts by default; 0 disables). - **`MaxAge` age clock**: measured from the last rotation, or — for a never-rotated store — from the **first launch under the policy** (the birth is stamped then). So adding `MaxAge` to an existing store does NOT rotate it immediately; a pre-existing install doesn't retroactively count as old. Each rotation restarts the clock. - **Rotation also switches on the authenticated v3 envelope**: after the first `rotateKeys()`, each ciphertext's AES-GCM AAD binds it to its identity + protection + unlock policy + generation, so a file-access attacker can't relocate a ciphertext or tamper its metadata and have it decrypt (reads fail closed). An un-rotated store's existing entries keep pre-3.0.0 bytes (new/rewritten strict `HARDWARE_ISOLATED` entries are the exception — they key under the 3.0.0 strict alias variant even at generation 1). Tell users who fear on-disk tampering to rotate once. AAD binds placement, NOT existence — it doesn't detect deletion or same-slot rollback (needs an external signed manifest). Rotation also upgrades any entries still on the legacy pre-2.x envelope to the current format as a free side effect (relevant for stores upgraded from very old KSafe versions). - **Erasure honesty**: `deleteKey`/`clearAll` = cryptographic erasure (destroy the key → ciphertext is dead), NOT guaranteed physical byte-shredding. `clearAll()` empties the store via its API; it deliberately does not unlink the live file (that races writes). Hardware stores (Keystore/Keychain) give strong key erasure; the JVM software-fallback key file and web IndexedDB do not. Per platform: | Platform | Where the key lives | What delete does | Erasure strength | |---|---|---|---| | Android | Keystore/StrongBox (TEE/SE) + a wrapped software DEK in the store | `KeyStore.deleteEntry` + DEK-record removal | **Strong** — the secure element destroys the key | | iOS / macOS | Keychain (Secure Enclave for `HARDWARE_ISOLATED`) | `SecItemDelete` | **Strong** — SE keys are destroyed in hardware | | JVM Desktop | OS vault (DPAPI / login Keychain / libsecret), or a software fallback file | vault delete, or file overwrite | **Strong** with a vault; the fallback is a plaintext key file with no secure-erase guarantee | | Web | Non-extractable `CryptoKey` in IndexedDB | `IDBObjectStore.delete` | **Medium** — the key was never plaintext to JS, but browser storage reclamation is not a secure wipe | Ciphertext bytes are a separate matter: `clearAll()` empties the store through its normal API and deliberately does NOT unlink or shred the live file (that races concurrent writes), so a journaling filesystem, an SSD's wear-levelling, or a backup snapshot may retain remnants no userspace library can reach. The guarantee KSafe actually makes is cryptographic erasure (NIST SP 800-88 "Cryptographic Erase"): destroy the key and the ciphertext is unrecoverable regardless of surviving bytes. Rotation strengthens it — after `rotateKeys()` the superseded master for every entry that was re-encrypted is deleted, so that generation's ciphertext is cryptographically dead. A superseded master is kept while ANY entry still references it, so skipped/failed entries keep their old key alive until a later pass supersedes them too. - **Crash-safe with automatic same-generation resume (3.1.0+)**: the generation bump and an `"r":1` lifecycle marker are persisted before entries move. Each entry records the generation that decrypts it, so an interrupted pass leaves a readable mixed-generation store. The next KSafe instance resumes that SAME generation (also under `Never`), repeats the idempotent remainder, and changes the state to `"r":0` only after the master sweep. This is one lifecycle field, not a per-entry journal/rollback log. - **Normally skipped work has a bounded persisted next-instance budget (3.1.0+)**: a pass that returns normally writes `r:0`; when it has `skipped` entries it also writes `rp:N`, where `N = KSafeConfig.keyRotationRetryAttempts` (default 3; set 0 to disable). This is not a timestamp and starts no timer: the current KSafe instance does not try again. Each next instance consumes at most one attempt and atomically claims `r:0,rp:N -> r:1,rp:N-1` **before** retrying the SAME generation — no new key and no `ts` reset — including under `Never`. The decrement is durable, so a crash cannot refill the budget; `r:1,rp:0` resumes the final claimed attempt but cannot arm another. If `MaxAge` is already due, its fresh-generation rotation takes precedence. `failed` alone never arms automatic retry: it means a definitive problem (e.g. key gone/ciphertext corrupt), so do not promise that the entry is readable. - **3.0.0 compatibility guard**: released 3.0.0 key-generation records have no `r` field. Absence is treated as an old completed record, never as proof of a crash. On the first 3.1.0 startup KSafe adds `r:0`, preserves generation/timestamp, and does no resume, generation bump, entry rewrite, key sweep, or same-launch `MaxAge`. Normal policy runs from the next launch. If 3.0.0 really left a mixed-generation store, its entries remain readable and a later manual/due rotation moves them normally. Unknown `r` values are preserved and rejected fail-closed. - **Observability**: a background `MaxAge` pass logs `KSafe: MaxAge key-rotation pass -> generation N (rotated X, skipped Y, failed Z).` on success; crash recovery logs `KSafe: resumed interrupted key rotation at generation N (rotated X, skipped Y, failed Z).`; normal partial retry logs `KSafe: retried incomplete key rotation at generation N (rotated X, skipped Y, failed Z).` A failure logs `scheduled key rotation failed … will retry on a later launch` (`console.warn` on web). A manual `rotateKeys()` returns the same counts in its `KSafeRotationResult`; per-entry, read `getKeyInfo(key)?.keyGeneration`. - **`getOrCreateSecret` values are untouched** (only their envelope re-wraps) — rotation never breaks a SQLCipher database. - **Downgrade footgun — treat rotation (and any 3.0.0 strict write) as a one-way door**: a pre-3.0.0 binary can't resolve rotated or strict-variant keys, and its startup orphan sweep PERMANENTLY DELETES the rows it can't decrypt (typically on first launch). Upgrading back restores access only if that sweep never ran — back up before any planned downgrade. - Per-entry check: `ksafe.getKeyInfo(key)?.keyGeneration` (1 = never rotated). - Cost = one decrypt + one encrypt per entry (Keystore IPC-bound on Android) — call from a background coroutine on large stores. A second concurrent call on the same instance throws, but the guard is per-instance — still trigger rotation from ONE place. Two same-process instances rotating one `fileName` can't corrupt anything (the 3.0.0 per-store commit lock serializes commits, the rotation CAS, and key sweeps) but duplicate the work and can report spurious `skipped`/`failed` counts; a second PROCESS has no coordination at all and can genuinely race the superseded-key sweep (same-file multi-process is unsupported anyway). --- ## Custom serialization ```kotlin val json = Json { serializersModule = SerializersModule { contextual(UUID::class, UUIDSerializer) contextual(Instant::class, InstantSerializer) } } val ksafe = KSafe(config = KSafeConfig(json = json)) @Serializable data class User(@Contextual val id: UUID, val name: String) ``` --- ## Diagnostics — `KSafe.protectionInfo` and `KSafe.VERSION` ```kotlin val info = ksafe.protectionInfo // recomputed per access (2.1.1+): a runtime JVM degrade shows up live // 3.2.0+: on Android with a secure lock screen (API 35+ included) this does // ONE blocking store read per process (again after clearAll()) — call it // off the main thread, never in Activity.onCreate check(info.effectiveLevel >= KSafeProtectionLevel.SANDBOX_PROTECTED) { "Need sandbox-grade key protection; got ${info.custody}" } check(info.effectiveLevel >= info.intendedLevel) // STRENGTH: detect silent fallback (posture gate) // OPERABILITY (3.0.0+): "will encrypted writes actually SUCCEED?" — cross-platform, no platform code. // Different question from strength: a JVM / iOS-Simulator software fallback is weaker but WORKS, so // this stays true there. It's false only where encrypted ops genuinely can't run — today two cases: // a web page outside a secure context (no crypto.subtle), and a JVM whose OS vault EXISTS but failed // its construction self-test (locked keychain/keyring — "jvm_os_vault_degraded"; the no-vault-at-all // software fallback stays operational). Gate a login / first write on it. if (!info.isEncryptionOperational) error("KSafe: encryption unavailable — web: serve over HTTPS/localhost; JVM: unlock the OS keyring (or -Dksafe.jvm.keyVault=software)") analytics.log("ksafe_protection", "level" to info.effectiveLevel.name, // SOFTWARE | SANDBOX_PROTECTED | HARDWARE_BACKED | HARDWARE_ISOLATED "custody" to info.custody, // human-readable, never parse "notes" to info.notes.joinToString(","),// stable lowercase_snake codes "version" to info.kSafeVersion) // == KSafe.VERSION ``` Two distinct questions, two gates — don't conflate them: `effectiveLevel` (vs `intendedLevel`) answers *"how strong?"* — a weaker-but-working fallback still trips `!= intendedLevel`, so it's a posture/compliance bar. `isEncryptionOperational` answers *"does it work at all?"* — use it to gate a login or first encrypted write. Gating operability on the level inequality would wrongly block every iOS-Simulator run and every headless desktop without an OS keyring. Per-key audit: `ksafe.getKeyInfo(key)` → `KSafeKeyInfo(protection, storage, level, keyGeneration)`; prefer `.level` (same scale as `protectionInfo`; on web it also reports the non-secure-context degrade). On Apple (3.0.0+) `.level` reports the live Keychain custody of the entry's actual key — a `HARDWARE_ISOLATED` request served by a legacy plain key reads `HARDWARE_BACKED`, the iOS-Simulator file fallback reads `SOFTWARE` — a real audit of what each entry got, not an echo of the request. Android infers from the requested tier plus StrongBox capability, so a per-key silent downgrade is only detectable on Apple. Device capability probe: `ksafe.deviceKeyStorages`. --- ## ⚠️ Compose Desktop release distributables — strongly recommend `modules("jdk.unsupported")` For any production Compose Desktop release build, add these modules — they give KSafe **OS-backed key custody** (Keychain / DPAPI / Secret Service), a core KSafe guarantee: ```kotlin compose.desktop { application { nativeDistributions { // jdk.unsupported → OS-backed key custody + DataStore. JNA and DataStore's // protobuf both need sun.misc.Unsafe, which lives in jdk.unsupported and // which jlink can't detect statically, so it's trimmed from release builds. // java.management → only for a non-default KSafeSecurityPolicy (WarnOnly / // Strict / custom debugger probe). Default IGNORE policy → omit. modules("jdk.unsupported", "java.management") } } } ``` **Why:** `jlink` builds a trimmed JRE with only the modules it can statically detect. Two things KSafe needs `sun.misc.Unsafe` for aren't detectable — JNA (the OS keyvault) and DataStore's embedded protobuf (its normal storage serializer). **Without the module (KSafe 2.1.1+) the app does NOT crash.** KSafe detects the missing `Unsafe` at construction and switches to a no-`Unsafe` software backend. Only the *key location* changes — storage and encryption do not: - Storage stays Jetpack `datastore-core` (same atomic writes / coordinator / fsync), just a custom JSON serializer instead of the protobuf. - Encryption stays AES-GCM; new keys use the configured `KSafeAesKeySize` (`BITS_256` by default, `BITS_128` when explicitly selected). - The AES key drops from the OS store to a local `0700` file (`FileKeyVault`) — KSafe's `SOFTWARE` tier, the same one used when no OS keyring is reachable. `protectionInfo.effectiveLevel` reports `SOFTWARE`. **Risk of the software tier (so you can advise correctly):** the key file (`…ksafe-keys.json`) holds the raw AES key Base64-encoded *in the clear*; anyone who can read it plus the ciphertext (`…ksafe.json`) can decrypt everything — the only barrier is the `0700` permission. The real exposure is off-host / same-user (an unencrypted backup, a copied/synced home dir, a stolen drive). That's why the module — which moves the key into the OS store — is recommended for production. **Migration:** when the module is added later, KSafe migrates the fallback data forward automatically on first launch — re-encrypting each entry under a freshly minted OS-backed key (the just-used fallback values win) and renaming the old files to `*.migrated`. Dev runs (`./gradlew run`) use the full local JDK and are unaffected. Why the fallback exists: before KSafe 2.1.1 a jlink'd image without `jdk.unsupported` failed in one of two ways, depending on the DataStore build — the write path is `encrypt` (JNA) → `DataStore.write` (protobuf), and both need `sun.misc.Unsafe`. A build that tolerated the missing `Unsafe` dropped writes **silently**; one whose protobuf hard-requires it **crashed on the first read**. 2.1.1's JSON fallback loads no protobuf, so neither happens — it degrades to a software key tier instead of losing data. --- ## Multi-app desktop / web isolation — `appNamespace` Android/iOS keystores are sandboxed per-app. **JVM Desktop OS secret stores are per-OS-user (shared across processes)**; **web IndexedDB is per-origin**. Two apps using the same `fileName` collide on the same key. Set: ```kotlin val ksafe = KSafe(fileName = "userdata", config = KSafeConfig(appNamespace = "com.example.myapp")) ``` Production desktop apps should set it explicitly. An explicit `appNamespace` namespaces **both** the key-store destination **and** the data file (a per-namespace subdirectory), so keys and ciphertext always move together. When unset, the namespace is a **stable shared constant** (since 2.1.2 it is never derived from the launcher/jar name — that derivation changed on every versioned release and orphaned the keys), so two no-namespace apps under the same OS user share a key namespace: that's exactly why production apps set it. Can also be set without code: `-Dksafe.appNamespace=…` or env `KSAFE_APP_NAMESPACE`. --- ## ANTI-patterns (common mistakes — DO NOT generate this code) ❌ **Don't forward a non-reified `T` to `ksafe.get` / `ksafe.put`** inside a generic wrapper — it does not compile ("Cannot use 'T' as reified type parameter"). Use the `KSerializer` overloads; see "Repository data source". ❌ **Don't look for a `KClass` overload.** There is none; pass a `KSerializer`. ❌ **Don't use `ksafe(value, encrypted = true)`.** `encrypted: Boolean` is **deprecated**. KSafe is encrypted by default: `ksafe(value)` encrypts, `ksafe(value, mode = KSafeWriteMode.Plain)` opts out. There is no `default =` named param — the default value is the first positional argument. ❌ **Don't pass a bare `null` default.** `ksafe.get("k", null)` / `var x by ksafe(null)` always return null (reified `T` collapses to `Nothing?`). Use `get("k", null)` or a typed declaration. ❌ **Don't wrap a delegate in `MutableStateFlow`.** KSafe is already reactive — use `ksafe.asMutableStateFlow(default, scope)` (writable) or `ksafe.asStateFlow(default, scope)` / `ksafe.asFlow(default)` (read-only). ❌ **Don't call `ksafe.getStateFlow(...)` more than once for the same key.** Each call runs `stateIn()` and launches a fresh watcher coroutine in the scope, so an inline call, a call in a loop, or one inside a `@Composable` leaks one watcher per call until that scope dies. Assign it to a `val` once, or use `by ksafe.asStateFlow(default, scope)`, which caches its `StateFlow` on first read. `getFlow` is cold — calling it repeatedly is free. ❌ **Don't `runBlocking { ksafe.put(...) }`.** Use the delegate, `putDirect` for fire-and-forget, or suspend `put` from a coroutine. ❌ **Don't use `KSafeProtection` in `KSafeWriteMode.Encrypted(...)`.** That constructor takes `KSafeEncryptedProtection`. `KSafeProtection` is the read-side detection enum. ❌ **Don't call `getOrCreateSecret` / `verifyBiometric` / `awaitCacheReady` synchronously.** They're `suspend`. ❌ **Don't roll your own `BiometricPrompt` / `LAContext`.** Add `:ksafe-biometrics` and call `KSafeBiometrics.verifyBiometric(...)`. ❌ **Don't ask for `HARDWARE_ISOLATED` by default.** It is slower and hardware-dependent. The default encrypted mode already has platform-backed durable custody: Android relaxed DEFAULT uses a Keystore KEK plus wrapped DEK, while Apple DEFAULT uses Keychain custody. Reserve `HARDWARE_ISOLATED` for master passphrases / identity keys that justify the stronger per-entry route and its possible fallback. ❌ **Don't pass `Activity` context on Android.** Always `applicationContext`. ❌ **Don't create two `KSafe` instances for the same `fileName`.** Singletons via DI. (Safe-but-wasteful on Android/iOS/macOS/JVM since 2.1.2; still diverges on web.) ❌ **Don't name keys `encrypted_*` or `__ksafe_*`**, and don't end a key with a `__ksafe_…__` sentinel segment behind a `.` or `:` (`__ksafe_master__`, `__ksafe_master_locked__`, `__ksafe_strict__`, `__ksafe_gen__`, `__ksafe_nsdel__`, `__ksafe_swfb__`) — reserved namespaces (3.0.0+): every write/delete throws `IllegalArgumentException` at the call site (reads still work). Name the key after the data, not the treatment — `"token"`, not `"encrypted_token"`; everything is encrypted by default anyway. ❌ **Don't try to rotate a single key.** `rotateKeys()` is whole-store — there is no per-key overload. It's `suspend`; call it off the main thread for large stores, and trigger it from ONE place per `fileName`. ❌ **Don't access one `fileName` from two processes.** KSafe currently wires a single-process coordinator and process-local cache/write queue; give a widget/service process its own `fileName`. ❌ **Don't forget `appNamespace` on JVM Desktop / web** if multiple apps share a `fileName`. --- ## "Data isn't persisted" — debugging checklist 1. `println(ksafe.protectionInfo)` — read `effectiveLevel`, `custody`, `notes`: - `jvm_os_vault_unavailable` → no OS secret store on this host; keys fall back to software. Weaker than intended but OPERATIONAL (encrypted ops still work). - `jvm_os_vault_degraded` → an OS vault EXISTS but was unreachable at construction (locked keychain/keyring, headless). KSafe refuses to mint keys, so encrypted ops throw — NON-operational (`isEncryptionOperational` is `false`). Retry once it's reachable, or set `-Dksafe.jvm.keyVault=software`. On Compose Desktop release also see the `jdk.unsupported` section above. - `jvm_user_opted_out` → `-Dksafe.jvm.keyVault=software` is set. - `android_strongbox_absent` → only matters for `HARDWARE_ISOLATED`. - `android_lock_screen_absent` → API 28-34 device with no secure lock screen; `requireUnlockedDevice` keys are minted without the unlock binding (writes still work, per-op TEE path). Reported until a new key generation re-decides — the note stays after the user sets a lock screen, because the minted key is not re-bound. Rotation is opt-in (`keyRotationPolicy` defaults to `Never`), so call `rotateKeys()` once to get the binding back. Never on API 35+. - `apple_secure_enclave_absent` → simulator or pre-T2 Intel Mac. - `apple_keychain_entitlement_missing` → iOS Simulator app with no Keychain entitlement (2.2.1+; keys transparently fall back to a sandbox file store so encrypted writes keep working — never emitted on a real device). - `web_crypto_subtle_unavailable` → web page is not a secure context, so `crypto.subtle` is absent and **every encrypted write fails** (unlike the fallbacks above, this one is non-operational, not just weaker). `effectiveLevel` drops to `SOFTWARE` and `isEncryptionOperational` is `false`. Serve over HTTPS or from a `localhost` origin. Preflight with `if (!ksafe.protectionInfo.isEncryptionOperational) …`. (3.0.0+) 2. On JVM, check stderr for `KSafe SECURITY WARNING` (printed once on vault degrade). 3. `ksafe.getKeyInfo(key)` — `null` means the key was never written. 4. Android: confirm `applicationContext` (not Activity). 5. Web: confirm `awaitCacheReady()` ran before the first `getDirect` on an encrypted key. 6. Reading null despite a stored value? The reified-`null` trap — see Nullable values. 7. From 2.1.1+, persistent write-consumer failures log `KSafe SEVERE` with the exception class. Search stderr. 8. **JVM: encrypted writes throwing at launch?** The OS keyring was unreachable when the instance was constructed (locked keychain, SSH/headless session, keyring not yet on D-Bus). Since 2.1.2 KSafe **fails closed** instead of minting keys it would later mistake for real ones — existing data is intact and readable again once the keyring is back; reads meanwhile return defaults without deleting anything. A one-time actionable warning is printed; `-Dksafe.jvm.keyVault=software` opts out for keyring-less hosts. 9. **Store suddenly empty, but a `.corrupt-` file sits next to it?** The store file was unreadable (truncated/garbled); since 2.1.2 KSafe quarantines the corrupt bytes there and continues from an empty store instead of crashing forever. The original bytes are preserved for manual recovery. 10. **JVM software key tier: `KSafe: key vault file is blank (truncated?)`?** The key file (`…ksafe-keys.json`) exists but is zero-byte — truncation (disk full, interrupted copy, restored backup), never a fresh store. Since 3.0.0 this fails closed instead of counting as an empty vault (which used to let the orphan sweep delete recoverable ciphertext): the encrypted data stays on disk and decrypts again once the key file is restored from backup. Without a backup of the key file the values are unrecoverable — delete the blank file to start fresh. 11. iOS Simulator: `Keychain error -34018` (`errSecMissingEntitlement`) on encrypted writes → the app has no Keychain entitlement (no signing team / no Keychain Sharing capability). Through 2.1.3 every encrypted write fails (suspend `put` throws; `putDirect` logs `KSafe SEVERE` and silently drops the write); from 2.2.1 KSafe auto-falls back to a sandbox file key store and just works. Either way the proper Xcode fix — select a Team and/or add the Keychain Sharing capability — restores real Keychain behavior. Real devices are unaffected (and never use the fallback). --- ## Scope of this skill This skill is self-contained: everything above is what you need to set up KSafe, choose write modes and protection tiers, persist state, gate on biometrics, rotate keys, and diagnose the failures that actually happen. Answer from it directly rather than deferring. Beyond that scope lie the library's internals (the hot cache and write coalescer, the envelope formats and their alias grammar), formal threat models, and measured benchmark tables against other libraries. Those change per release and are not reproduced here — if a question needs them, say so plainly and read the current source rather than guessing, since a remembered number or an internal you half-recall is worse than an honest "let me check". Two things go stale fastest and should never be answered from memory: **benchmark figures** (they are device-, build- and store-size-specific — a debug build alone moves them severalfold) and **comparison claims about other libraries** (verify against that library's own current release notes, never a table you have seen before). --- ## Quick reference card ```kotlin // Construct val ksafe = KSafe(applicationContext) // Android val ksafe = KSafe() // everywhere else val ksafe = KSafe(fileName = "session") // named instance val ksafe = KSafe(config = KSafeConfig(appNamespace = "com.example.app")) // Delegate (preferred — ENCRYPTED BY DEFAULT; default value is positional) var token by ksafe("") // encrypted var counter by ksafe(0, mode = KSafeWriteMode.Plain) // opt out var theme by ksafe("light", key = "app_theme") // custom key var nul: String? by ksafe(null) // nullable: type the declaration val c = ksafe(0, key = "counter"); c.value++ // 3.2.0+: no-`by` handle; .value needs the key // Mode-typed views (3.1.0+) — the write mode is the TYPE, no mode argument exists val prefs = KSafePlain(ksafe); val vault = KSafeHardwareIsolated(ksafe) // or ksafe.plain / .hardwareIsolated prefs.putDirect("theme", "dark") // always Plain var pin by vault("") // always requests SE/StrongBox // store ops (rotateKeys/clearAll/protectionInfo/...) live on view.ksafe, not the view // Compose (:ksafe-compose) var pin by ksafe.mutableStateOf("") // class field — ENCRYPTED default var n by ksafe.mutableStateOf(0, scope = viewModelScope) // + live cross-screen sync @Composable fun X() { var x by ksafe.rememberKSafeState(0, key = "x") } // body — PLAIN default // Suspend (key first, then defaultValue) val v = ksafe.get(key, defaultValue); ksafe.put(key, value); ksafe.delete(key); ksafe.clearAll() // Direct (fire-and-forget) val v = ksafe.getDirect(key, defaultValue); ksafe.putDirect(key, value); ksafe.deleteDirect(key) // Explicit serializer (3.3.0+) — for a non-reified T; repository data source = SecureStore + KSafeSecureStore + FakeSecureStore ksafe.get(key, default, serializer); ksafe.put(key, value, serializer, mode); ksafe.getDirect(key, default, serializer) // Reactive (delegates — defaultValue first) val f: Flow by ksafe.asFlow("Guest") val wf: WritableKSafeFlow by ksafe.asWritableFlow(default) // .set(v) persists val sf: StateFlow by ksafe.asStateFlow("Guest", viewModelScope) val ms by ksafe.asMutableStateFlow(State(), viewModelScope) // .value=/.update{} ksafe.getFlow(key, defaultValue).collect { … } // Diagnostics ksafe.protectionInfo // live KSafeProtectionInfo (effectiveLevel, custody, notes, kSafeVersion); Android 3.2.0+: off the main thread ksafe.protectionInfo.isEncryptionOperational // 3.0.0+: false where encrypted ops can't run (web non-secure / JVM OS vault unreachable) ksafe.getKeyInfo(key) // per-key KSafeKeyInfo (prefer .level) ksafe.deviceKeyStorages // platform capability tiers KSafe.VERSION // linked artifact version // Key rotation (3.0.0+) val r = ksafe.rotateKeys() // suspend; WHOLE-store (no per-key); KSafeRotationResult(rotated, skipped, failed, keyGeneration) KSafe(config = KSafeConfig( keyRotationPolicy = KSafeKeyRotationPolicy.MaxAge(90.days), keyRotationRetryAttempts = 3, // default; 0 disables skipped-work retries )) // auto, background ksafe.getKeyInfo(key)?.keyGeneration // 1 = never rotated // Biometrics (:ksafe-biometrics — static, suspend verifyBiometric / callback verifyBiometricDirect) suspend fun a() = KSafeBiometrics.verifyBiometric(reason) // Boolean KSafeBiometrics.verifyBiometricDirect(reason) { success -> } suspend fun avail() = KSafeBiometrics.biometricsAvailable() // real prompt possible? (false = pass-through) KSafeBiometrics.biometricsAvailableDirect { available -> } // callback twin of biometricsAvailable KSafeBiometrics.clearBiometricAuth(scope = null) // invalidate cached auth (logout / lock) // Secrets — getOrCreateSecret is SUSPEND suspend fun s() { val pw: ByteArray = ksafe.getOrCreateSecret("name") } // 256-bit, hw-isolated val nonce = secureRandomBytes(16) // platform CSPRNG // Web only suspend fun boot() { ksafe.awaitCacheReady() } ```