Ktormonitor
CosminMihuMDC/KtorMonitor
KtorMonitor is a Kotlin Multiplatform library for real-time HTTP traffic monitoring.
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.
$ npx skills add ioannisa/KSafe --skill ksafe -a claude-codeProject install by default; add -g for ~/.claude/skills/.
$ gh skill install ioannisa/KSafe ksafe --agent claude-codeProject scope by default; add --scope user for a personal install. Needs GitHub CLI 2.90.0 or later (public preview).
$ git clone --depth 1 https://github.com/ioannisa/KSafe.git skills-src && mkdir -p .claude/skills && cp -r skills-src/skills/ksafe .claude/skills/ksafe && rm -rf skills-srcUse ~/.claude/skills/ instead of .claude/skills for a personal install. The folder must contain SKILL.md.
Claude Code skills documentation · loads skills from .claude/skills/
Install the "ksafe" agent skill from https://github.com/ioannisa/KSafe/tree/main/skills/ksafe into .claude/skills/ksafe/ in this project. Copy the whole folder (SKILL.md and every file beside it), keep the folder name "ksafe", then confirm the skill loads.Claude Code copies the folder itself, the same result as the manual copy. Check what it changed before you commit it.
$skill-installer install https://github.com/ioannisa/KSafe/tree/main/skills/ksafeType this inside Codex. $skill-installer <name> installs a curated skill from openai/skills. The installer writes to $CODEX_HOME/skills (default ~/.codex/skills). Restart Codex if the skill does not show up.
$ npx skills add ioannisa/KSafe --skill ksafe -a codexProject install goes to .agents/skills/; add -g for ~/.codex/skills/.
$ gh skill install ioannisa/KSafe ksafe --agent codexProject scope by default (.agents/skills/); add --scope user for a personal install.
$ git clone --depth 1 https://github.com/ioannisa/KSafe.git skills-src && mkdir -p .agents/skills && cp -r skills-src/skills/ksafe .agents/skills/ksafe && rm -rf skills-srcUse ~/.agents/skills/ instead of .agents/skills for a personal install.
Codex skills documentation · loads skills from .agents/skills/
Install the "ksafe" agent skill from https://github.com/ioannisa/KSafe/tree/main/skills/ksafe into .agents/skills/ksafe/ in this project. Copy the whole folder (SKILL.md and every file beside it), keep the folder name "ksafe", then confirm the skill loads.Codex copies the folder itself, the same result as the manual copy. Check what it changed before you commit it.
$ npx skills add ioannisa/KSafe --skill ksafe -a cursorProject install goes to .agents/skills/; add -g for ~/.cursor/skills/.
$ gh skill install ioannisa/KSafe ksafe --agent cursorProject scope by default (.agents/skills/); add --scope user for a personal install.
$ git clone --depth 1 https://github.com/ioannisa/KSafe.git skills-src && mkdir -p .cursor/skills && cp -r skills-src/skills/ksafe .cursor/skills/ksafe && rm -rf skills-srcUse ~/.cursor/skills/ instead of .cursor/skills for a personal install.
Cursor skills documentation · loads skills from .cursor/skills/, .agents/skills/, .claude/skills/, .codex/skills/
Install the "ksafe" agent skill from https://github.com/ioannisa/KSafe/tree/main/skills/ksafe into .cursor/skills/ksafe/ in this project. Copy the whole folder (SKILL.md and every file beside it), keep the folder name "ksafe", then confirm the skill loads.Cursor copies the folder itself, the same result as the manual copy. Check what it changed before you commit it.
$ gemini skills install https://github.com/ioannisa/KSafe.git --path skills/ksafe--scope user (default) or --scope workspace; --path is the subfolder of the repo that holds the skill; --consent skips the security confirmation prompt.
$ npx skills add ioannisa/KSafe --skill ksafe -a gemini-cliProject install goes to .agents/skills/; add -g for ~/.gemini/skills/.
$ gh skill install ioannisa/KSafe ksafe --agent gemini-cliProject scope by default (.agents/skills/); add --scope user for a personal install.
$ git clone --depth 1 https://github.com/ioannisa/KSafe.git skills-src && mkdir -p .gemini/skills && cp -r skills-src/skills/ksafe .gemini/skills/ksafe && rm -rf skills-srcUse ~/.gemini/skills/ instead of .gemini/skills for a personal install, then run /skills reload.
Gemini CLI skills documentation · loads skills from .gemini/skills/, .agents/skills/
Install the "ksafe" agent skill from https://github.com/ioannisa/KSafe/tree/main/skills/ksafe into .gemini/skills/ksafe/ in this project. Copy the whole folder (SKILL.md and every file beside it), keep the folder name "ksafe", then confirm the skill loads.Gemini CLI copies the folder itself, the same result as the manual copy. Check what it changed before you commit it.
$ gh skill install ioannisa/KSafe ksafeInstalls for Copilot at project scope by default; add --scope user for a personal install. Preview a skill first with gh skill preview. Needs GitHub CLI 2.90.0 or later (public preview).
$ npx skills add ioannisa/KSafe --skill ksafe -a github-copilotProject install goes to .agents/skills/; add -g for ~/.copilot/skills/.
$ git clone --depth 1 https://github.com/ioannisa/KSafe.git skills-src && mkdir -p .github/skills && cp -r skills-src/skills/ksafe .github/skills/ksafe && rm -rf skills-srcUse ~/.copilot/skills/ instead of .github/skills for a personal install. Commit .github/skills so cloud agent and code review can use it.
GitHub Copilot skills documentation · loads skills from .github/skills/, .claude/skills/, .agents/skills/
Install the "ksafe" agent skill from https://github.com/ioannisa/KSafe/tree/main/skills/ksafe into .github/skills/ksafe/ in this project. Copy the whole folder (SKILL.md and every file beside it), keep the folder name "ksafe", then confirm the skill loads.GitHub Copilot copies the folder itself, the same result as the manual copy. Check what it changed before you commit it.
$ npx skills add ioannisa/KSafe --skill ksafe -a opencodeOpenCode documents no install command of its own. Project install goes to .agents/skills/; add -g for ~/.config/opencode/skills/.
$ gh skill install ioannisa/KSafe ksafe --agent opencodeProject scope by default (.agents/skills/); add --scope user for a personal install.
$ git clone --depth 1 https://github.com/ioannisa/KSafe.git skills-src && mkdir -p .opencode/skills && cp -r skills-src/skills/ksafe .opencode/skills/ksafe && rm -rf skills-srcUse ~/.config/opencode/skills/ instead of .opencode/skills for a personal install.
OpenCode skills documentation · loads skills from .opencode/skills/, .claude/skills/, .agents/skills/
Install the "ksafe" agent skill from https://github.com/ioannisa/KSafe/tree/main/skills/ksafe into .opencode/skills/ksafe/ in this project. Copy the whole folder (SKILL.md and every file beside it), keep the folder name "ksafe", then confirm the skill loads.OpenCode copies the folder itself, the same result as the manual copy. Check what it changed before you commit it.
ksafeRequired 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 is an agent skill from ioannisa/KSafe. 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…
Its SKILL.md is about 17k tokens, which your agent loads only when the skill is triggered. It is a single SKILL.md file with no bundled scripts.
It sits in Mobile, covering Android development, Cross-platform mobile apps and iOS development. It works with Kotlin, Android, Jetpack Compose and iOS. The repository describes itself as: A library for saving key/value pair data for Kotlin Multiplatform and Android. Encryption enabled by default, with option for Plain (unencrypted) storage. Supports Property… The licence is Apache-2.0.
11 steps, taken from the first numbered list in SKILL.md.
Read from SKILL.md and the folder at commit 1373547. It shows what the files ask for, not the result of running them.
Pre-approves nothing: there is no allowed-tools line, so your agent's usual permission prompts apply.
From allowed-tools in the SKILL.md frontmatter.
No scripts in the folder and no shell commands in SKILL.md (its code samples are kotlin).
From the folder's file list and the shell code blocks in SKILL.md.
No URLs in SKILL.md.
From URLs in SKILL.md, links to its own repository left out.
Names no API keys, tokens, secrets or passwords.
From names ending in _API_KEY, _TOKEN, _SECRET, _KEY or _PASSWORD in SKILL.md.
Ksafe loads about 17k tokens when it runs. Until then it costs about 247 tokens; SKILL.md has 5,770 words of instructions outside code blocks.
Estimates: characters ÷ 4, the usual rule of thumb; real counts depend on the model's tokenizer. Scripts and assets cost tokens only if the agent reads them.
The automated check found no risky patterns in SKILL.md.
Automated static check — not a guarantee. Review scripts before installing. It scans the text of SKILL.md for risky patterns (piping downloads into a shell, reading credential files, hidden Unicode, destructive commands); files beside SKILL.md are not scanned.
The full file from ioannisa/KSafe at commit 1373547, republished under its Apache-2.0 licence (© ioannisa). 5,770 words, ~16,782 tokens.
.claude/skills/ksafe/SKILL.md (or your agent's skills folder).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.
| 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.
// 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 promptskotlinx-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 instanceFull 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, // 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
)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.
// 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()) }
}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:
prefs.get() reads an encrypted entry fine.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.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.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:
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: 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.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.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<hex>), 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.// ✅ 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'TDefaults 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. 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.
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()
}
}
}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):
// 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) = UnitDefault 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.
ksafe(default, key) without by (3.2.0+)The same call, given an explicit key, returns a KSafeReference<T> 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.
class SessionRepository(ksafe: KSafe) {
private val token = ksafe("", key = "auth_token") // KSafeReference<String>, 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..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.encrypted: Boolean overload still returns the delegate type. Never
generate it (see above); if you meet one, move it to mode before using .value.@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).
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<T> 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").
// Data layer: the only storage type repositories see.
interface SecureStore {
suspend fun <T> get(key: String, defaultValue: T, serializer: KSerializer<T>): T
suspend fun <T> put(key: String, value: T, serializer: KSerializer<T>)
fun <T> observe(key: String, defaultValue: T, serializer: KSerializer<T>): Flow<T>
suspend fun delete(key: String)
}
// Short call sites: store.get("theme", "light"), store.put("ids", ids), store.observe("info", Info()).
suspend inline fun <reified T> SecureStore.get(key: String, defaultValue: T): T = get(key, defaultValue, serializer())
suspend inline fun <reified T> SecureStore.put(key: String, value: T) = put(key, value, serializer())
inline fun <reified T> SecureStore.observe(key: String, defaultValue: T): Flow<T> = 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 <T> get(key: String, defaultValue: T, serializer: KSerializer<T>): T =
ksafe.get(key, defaultValue, serializer)
override suspend fun <T> put(key: String, value: T, serializer: KSerializer<T>) =
ksafe.put(key, value, serializer, mode)
override fun <T> observe(key: String, defaultValue: T, serializer: KSerializer<T>): Flow<T> =
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<Map<String, Any?>>(emptyMap())
@Suppress("UNCHECKED_CAST")
override suspend fun <T> get(key: String, defaultValue: T, serializer: KSerializer<T>): T =
if (key in values.value) values.value[key] as T else defaultValue
override suspend fun <T> put(key: String, value: T, serializer: KSerializer<T>) =
values.update { it + (key to value) }
@Suppress("UNCHECKED_CAST")
override fun <T> observe(key: String, defaultValue: T, serializer: KSerializer<T>): Flow<T> =
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<String?>("access_token", null)
suspend fun saveAccessToken(token: String?) = store.put("access_token", token)
val authInfo: Flow<AuthInfo> = store.observe("auth_info", AuthInfo())
}
// Koin: one store per sensitivity, still one KSafe file.
single<SecureStore> { KSafeSecureStore(get()) } // encrypted
single<SecureStore>(named("prefs")) { KSafeSecureStore(get(), KSafeWriteMode.Plain) } // non-secretRules for this pattern:
fun <T> get(...)) is implemented with getDirect / putDirect; a
suspend one with get / put.KClass overload. Pass a KSerializer: User.serializer(),
ListSerializer(User.serializer()), or serializer<T>() inside a reified function.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.mode parameter above) instead.SecureStore once more than one
repository persists data: one fake then covers all of them.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.
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.
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.
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.
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 — 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):
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 nulltitle 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.
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.
Re-encrypt every encrypted entry under fresh key material — values never change, nothing migrates, works on every platform:
val r = ksafe.rotateKeys() // suspend; KSafeRotationResult(rotated, skipped, failed, keyGeneration)Or declaratively (checked once per startup, runs in the background, never blocks):
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).
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
// 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.VERSIONTwo 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.
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.KSafeAesKeySize
(BITS_256 by default, BITS_128 when explicitly selected).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.
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 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<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 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.
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+)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.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.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).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).
// 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<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); 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() }© ioannisa, Apache-2.0. Rendered from Markdown: HTML in the file is shown as text, images as links, and headings moved down two levels. Raw file
Just SKILL.md in skills/ksafe of ioannisa/KSafe.
Open the folder on GitHubat commit 1373547
Ksafe next to the 5 skills that share the most tags, products or categories with it. Stars are the repository's; “used in” counts other GitHub owners with a copy.
| Skill | Stars | Used in | Tokens | Auto-check | Licence | Repo updated |
|---|---|---|---|---|---|---|
| Ksafe this skillioannisa/KSafe | 332 | — | ~17k | Automated safety check: Pass | Apache-2.0 | |
| KtormonitorCosminMihuMDC/KtorMonitor | 254 | — | ~2.5k | Automated safety check: Pass | Apache-2.0 | |
| Compose Multiplatformericrisco/rsc-harness | 174 | — | ~2.7k | Automated safety check: Pass | MIT | |
| Expo Brownfield Integrationmweinbach/agent-coworker | 156 | 2 repos | ~900 | Automated safety check: Notes | Custom licence | |
| Jetpack Composedarriousliu/PiPixiv | 263 | — | ~1.5k | Automated safety check: Pass | Apache-2.0 | |
| Simulator Audio E2Ehyochan/react-native-nitro-sound | 961 | — | ~1.1k | Automated safety check: Pass | MIT |
CosminMihuMDC/KtorMonitor
KtorMonitor is a Kotlin Multiplatform library for real-time HTTP traffic monitoring.
ericrisco/rsc-harness
A skill your agent uses when building one shared Compose UI in Kotlin across Android, iOS, and desktop — commonMain @Composables, expect/actual, source-set placement, native interop, multiplatform…
mweinbach/agent-coworker
Helps add Expo and React Native to an existing native iOS or Android app, and choose between a prebuilt AAR or XCFramework and a fully integrated build.
darriousliu/PiPixiv
Jetpack Compose expert skill for Android UI development. An agent skill from darriousliu/PiPixiv.
hyochan/react-native-nitro-sound
Build and run repeatable react-native-nitro-sound recorder/player regression tests on an iOS Simulator or Android emulator, with explicit virtual-device selection, microphone permission, Maestro…
arcboxlabs/linkcode
Framework (OSS). An agent skill from arcboxlabs/linkcode.
Categories
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 is an agent skill from ioannisa/KSafe.' question or a one-line change that looks like plain Kotlin.
Ksafe fits situations like: KSafe is unnamed but Kotlin/Compose Multiplatform code must keep secrets; settings on device: tokens; encrypted prefs; A testable local data source.
Run `npx skills add ioannisa/KSafe --skill ksafe -a claude-code`. Or copy the skill folder (skills/ksafe in ioannisa/KSafe) into .claude/skills/ksafe in your project. Claude Code loads it when a task matches its description.
Run `npx skills add ioannisa/KSafe --skill ksafe -a codex`. Or copy the skill folder (skills/ksafe in ioannisa/KSafe) into .agents/skills/ksafe in your project. Codex loads it when a task matches its description.
Cursor, Gemini CLI, GitHub Copilot and OpenCode also load SKILL.md folders. With the skills CLI, run `npx skills add ioannisa/KSafe --skill ksafe -a cursor` (or -a gemini-cli, github-copilot or opencode for the others). To copy it by hand, put the folder in .cursor/skills/ksafe, .gemini/skills/ksafe, .github/skills/ksafe and .opencode/skills/ksafe in your project.
SKILL.md names no scripts, command-line tools or credentials: Ksafe is instructions for the agent only.
SKILL.md contains no URLs. Any network use would come from the scripts or tools the agent runs. This is read from the text; nothing was executed.
Our automated static check of SKILL.md found no risky patterns, such as piping downloads into a shell, reading credential files or hidden Unicode. It is not a guarantee. Review the folder before installing.
Ksafe is published under the Apache-2.0 licence (the repository's licence). It allows redistribution, so the full SKILL.md is shown on this page.
About 17k tokens (SKILL.md is roughly 67k characters). Agents keep only the skill's name and description in context until a task matches; then they load SKILL.md in full.
Skills that share tags, products or a category with Ksafe: Ktormonitor (CosminMihuMDC/KtorMonitor, 254 stars), Compose Multiplatform (ericrisco/rsc-harness, 174 stars), Expo Brownfield Integration (mweinbach/agent-coworker, 156 stars) and Jetpack Compose (darriousliu/PiPixiv, 263 stars). The comparison table on this page puts their stars, adoption, token cost, safety result and licence side by side.
ioannisa (a GitHub user) maintains it in ioannisa/KSafe, which has 332 GitHub stars. The repository was last updated on October 3, 2026.
Source: ioannisa/KSafe on GitHub. Facts on this page come from the repository at the commit we read; the author's words are quoted as theirs.