From ksafe
Encrypted key-value persistence via KSafe for Kotlin Multiplatform projects. Manages tokens, passwords, and secrets across Android, iOS, macOS, JVM Desktop, and web with platform-appropriate secure storage (Keystore, Keychain, DPAPI, WebCrypto).
How this skill is triggered — by the user, by Claude, or both
Slash command
/ksafe:ksafeThe summary Claude sees in its skill listing — used to decide when to auto-load this skill
You are about to write or modify code that uses **KSafe**: a one-API encrypted key-value
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. AES-256-GCM throughout. The AES key always lives in the platform's strongest secure store (see the matrix below); the on-disk file holds only ciphertext.
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.
| Platform | Where the AES key lives | How it's hardened | "HARDWARE_ISOLATED" upgrade |
|---|---|---|---|
| Android | Android Keystore | TEE | StrongBox (per-write) |
| iOS / native macOS | Apple Keychain | Per-app sandbox; SEP-gated on modern hardware | Secure Enclave (per-write) |
| JVM Desktop | Windows DPAPI / macOS login Keychain / Linux Secret Service | Bound to OS user login | n/a |
| WasmJS / JS | IndexedDB | Non-extractable WebCrypto CryptoKey | n/a |
When a stronger tier isn't available (no StrongBox / no Secure Enclave / headless Linux
without a keyring), KSafe degrades to the next-best path and reports the degrade
through KSafe.protectionInfo. Never silent data loss.
// commonMain (or Android-only) build.gradle.kts
implementation("eu.anifantakis:ksafe:<latest>") // core
implementation("eu.anifantakis:ksafe-compose:<latest>") // optional: Compose state
implementation("eu.anifantakis:ksafe-biometrics:<latest>") // 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.
// 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):
KSafe(
context: Context, // Android ONLY — applicationContext
fileName: String? = null, // null = default instance; else isolates storage
lazyLoad: Boolean = false,
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(
keySize: Int = 256, // 128 or 256
androidAuthValiditySeconds: Int = 30,
requireUnlockedDevice: Boolean = false, // default unlock policy for encrypted writes
json: Json = KSafeDefaults.json, // custom serialization
appNamespace: String? = null, // multi-app isolation (see below)
)
prefs / vault two-instance patternEncryption 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. The
recommended pattern is two named singletons: a fast plain prefs and an encrypted
vault.
// commonMain
expect val platformModule: Module
// androidMain
actual val platformModule = module {
single(named("prefs")) { KSafe(context = androidApplication(), fileName = "prefs") }
single(named("vault")) { KSafe(context = androidApplication(), fileName = "vault") }
}
// iosMain / jvmMain / wasmJsMain / jsMain (no context)
actual val platformModule = module {
single(named("prefs")) { KSafe(fileName = "prefs") }
single(named("vault")) { KSafe(fileName = "vault") }
}
class MyViewModel(
private val prefs: KSafe, // @Named("prefs") — fast, write Plain
private val vault: KSafe, // @Named("vault") — encrypted secrets
) : ViewModel() {
// UI preferences — opt out of encryption
var theme by prefs("dark", mode = KSafeWriteMode.Plain)
var lastScreen by prefs("home", mode = KSafeWriteMode.Plain)
// Secrets — encrypted by default
var authToken by vault("")
var userPin by vault("", mode = KSafeWriteMode.Encrypted(KSafeEncryptedProtection.HARDWARE_ISOLATED))
}
If your app only stores secrets, a single default instance is fine:
actual val platformModule = module { single { KSafe(/* androidApplication() on Android */) } }
KSafe(fileName=...) should be a singleton. Create once (via DI), reuse everywhere.fileName are safe on Android / iOS / macOS / JVM
(they share one ref-counted backend; only the last close() tears it down) — but it's still
wasteful and still broken on web (per-instance caches diverge). Keep the singleton pattern.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.// ✅ 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
Defaults are platform-appropriate (Android app sandbox, iOS NSApplicationSupportDirectory,
JVM ~/.eu_anifantakis_ksafe/ at 0700, web localStorage). Override only when needed:
// 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-processThe 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.
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:
// 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<KSafe>().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<KSafe>().awaitCacheReady(); ready = true }
if (ready) AppContent()
}
}
}
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.
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.
@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.
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.
// ❌ 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<String?>("auth_token", null)
var token by ksafe<String?>(null)
// ✅ Correct — typed declaration drives inference
val token: String? = ksafe.get("auth_token", null)
var token: String? by ksafe(null)
// 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).
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.
// 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.
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.
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.
class Repo(private val ksafe: KSafe) {
// Cold Flow<T> — read-only. Encrypted by default; pass mode = Plain to opt out.
val username: Flow<String> by ksafe.asFlow("Guest")
val theme: Flow<String> by ksafe.asFlow("light", key = "app_theme")
// Writable cold Flow<T> — set() persists, no CoroutineScope needed.
val themeMode: WritableKSafeFlow<ThemeMode> by ksafe.asWritableFlow(ThemeMode.DEVICE)
fun setTheme(m: ThemeMode) = themeMode.set(m)
}
class VM(private val ksafe: KSafe) : ViewModel() {
// Hot StateFlow<T> — read-only. Needs a scope.
val username: StateFlow<String> by ksafe.asStateFlow("Guest", viewModelScope)
// Hot MutableStateFlow<T> — .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().
:ksafe-composeTwo APIs with deliberately different default modes:
// 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.
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().
: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.
// 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:
// suspend (+ biometricsAvailableDirect { } callback). Never shows UI, no gesture needed.
// Probe ONCE at startup (on web: next to awaitCacheReady()) and keep in app state.
if (KSafeBiometrics.biometricsAvailable()) { /* biometric flow */ } else { /* PIN screen */ }
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 (a second prompt queues behind the first).
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 → false |
| JVM Windows (2.2.1+) | Windows Hello (UserConsentVerifier) | strict + Hello not-configured → 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. 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.
getOrCreateSecret is a suspend extension — call from a coroutine.
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.
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)
KSafe.protectionInfo and KSafe.VERSIONval info = ksafe.protectionInfo // recomputed per access (2.1.1+): a runtime JVM degrade shows up live
check(info.effectiveLevel >= KSafeProtectionLevel.SANDBOX_PROTECTED) {
"Need sandbox-grade key protection; got ${info.custody}"
}
check(info.effectiveLevel >= info.intendedLevel) // detect silent fallback
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
Per-key audit: ksafe.getKeyInfo(key) → KSafeKeyInfo(protection, storage, level); prefer
.level (same scale as protectionInfo). Device capability probe: ksafe.deviceKeyStorages.
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:
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:
datastore-core (same atomic writes / coordinator / fsync), just a
custom JSON serializer instead of the protobuf.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. (The KSafe repo's
docs/JVM_PROTECTION.md has the deeper #32 history if a human wants it.)
appNamespaceAndroid/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:
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.
❌ 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<String?>("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 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. Slower, strict hardware requirements.
The default encrypted mode (KSafeEncryptedProtection.DEFAULT) is already TEE/SEP-backed
on modern hardware. Reserve HARDWARE_ISOLATED for master passphrases / identity keys.
❌ 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 access one fileName from two processes. DataStore is single-process;
give a widget/service process its own fileName.
❌ Don't forget appNamespace on JVM Desktop / web if multiple apps share a fileName.
println(ksafe.protectionInfo) — read effectiveLevel, custody, notes:
jvm_os_vault_unavailable → JVM OS vault degraded; on Compose Desktop release see the
jdk.unsupported section above.jvm_user_opted_out → -Dksafe.jvm.keyVault=software is set.android_strongbox_absent → only matters for HARDWARE_ISOLATED.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).KSafe SECURITY WARNING (printed once on vault degrade).ksafe.getKeyInfo(key) — null means the key was never written.applicationContext (not Activity).awaitCacheReady() ran before the first getDirect on an encrypted key.null trap — see Nullable values.KSafe SEVERE with the exception
class. Search stderr.-Dksafe.jvm.keyVault=software opts out for keyring-less hosts..corrupt-<timestamp> 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.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).docs/ folder)This skill covers setup and usage. For topics it deliberately omits, the repository's
docs/ folder has: ARCHITECTURE / TOUR (internals — hot cache, write coalescer,
v2 master-key envelope), SECURITY_MODEL / PROTECTION_INFO / JVM_PROTECTION (crypto
details, threat models, per-platform key custody deep dive), BENCHMARKS (performance vs
MMKV / SharedPreferences / KVault / Multiplatform Settings), MIGRATION (version upgrade
notes), TESTING, ENCRYPTION_PROOF, and COMPARISON. Point the user there (or read
them from the project's own checkout) when a question goes beyond setup/usage.
// 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
// 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)
// Reactive (delegates — defaultValue first)
val f: Flow<String> by ksafe.asFlow("Guest")
val wf: WritableKSafeFlow<T> by ksafe.asWritableFlow(default) // .set(v) persists
val sf: StateFlow<String> 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)
ksafe.getKeyInfo(key) // per-key KSafeKeyInfo (prefer .level)
ksafe.deviceKeyStorages // platform capability tiers
KSafe.VERSION // linked artifact version
// 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)
// 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() }
npx claudepluginhub ioannisa/ksafe --plugin ksafeGuides iOS/macOS security work: Keychain Services, biometric authentication, CryptoKit, Secure Enclave, credential storage, certificate pinning, and OWASP mobile compliance.
Stores sensitive mobile data using iOS Keychain and Android Keystore instead of plaintext files or SharedPreferences/UserDefaults. Follows OWASP MASVS secure storage requirements.
Provides Android security patterns for secure storage with EncryptedSharedPreferences and Keystore, network security configs, certificate pinning, input validation, and authentication.