Releases: ioannisa/KSafe
Release list
2.1.3
KSafe 2.1.3 - Security Patch
This is a critical security patch resolving a vulnerability where aggressive memory caching could bypass the requireUnlockedDevice hardware policy.
🔒 Security Fixes
- Fixed an in-memory cache bypass for
requireUnlockedDevice
Previously, a key-value pair written withrequireUnlockedDevice = truecould be read while the device was locked if the value or its decryption key had been cached in RAM during a prior unlocked state. This bypassed the native OS hardware security modules entirely. - Coordinated Cache Bypass Architecture
We have implemented a strict bypass mechanism across all architectural layers for keys requiring unlocked devices:- Core Level:
KSafeCorenow evaluates metadata before hitting theplaintextCacheormemoryCache, forcing a native read if an unlocked device is required. - Android Engine:
AndroidKeystoreEncryptionnow bypasses its software DEK cache and queries the Keystore hardware directly, restoring the nativeUserNotAuthenticatedExceptionon locked devices. - iOS/macOS Engine:
AppleKeychainEncryptionnow bypasses itskeyBytesCacheand queries the Keychain directly, strictly enforcing thekSecAttrAccessibleWhenUnlockedThisDeviceOnlypolicy.
Highly sensitive keys are now guaranteed to be cryptographically enforced by the underlying OS hardware on every read, fixing the lock bypass vulnerability on both Android and iOS.
- Core Level:
Full Changelog: 2.1.2...2.1.3
2.1.2
Highlights
-
Android
ENCRYPTED-memory reads are now ~570× faster on real hardware. Before 2.1.2, everyENCRYPTED-memory read ran its AES-GCM inside the Android Keystore (TEE) — a hardware round-trip measured at ~8 ms/op on a Galaxy S24 Ultra (emulators hid this at ~0.2 ms). 2.1.2 keeps the per-datastore master key (KEK) non-exportable in the TEE but uses it to wrap a data-encryption key (DEK) that is unwrapped once into memory; per-value AES-GCM then runs in userspace. On an S24 Ultra,ENCRYPTEDDirect read dropped 8.25 ms → 0.0144 ms — flipping KSafe from ~143×/161× slower to ~3.4×/2.6× faster than EncryptedSharedPreferences / KVault on the decrypt-every-read path. Brings Android in line with the Apple (CryptoKit) and JVM (JCE) engines. -
HARDWARE_ISOLATEDand strictrequireUnlockedDeviceare unchanged. Both keep their key in the TEE and decrypt there on every op — the DEK fast path applies only to the relaxedDEFAULTmaster (usable while the device is locked anyway), so no security property is lost. -
No migration, no data loss. DEK ciphertext is self-describing (
MAGIC || VERSIONheader), so pre-2.1.2 TEE ciphertext and new DEK ciphertext coexist. Existing values upgrade lazily on their next write. -
Cross-type numeric reads no longer silently lose data. A persisted primitive can now be read back as any other numeric type — the full
Int/Long/Float/Doublematrix coerces when the value is faithfully representable. PreviouslyFloat↔Doublereturned the default (silent data loss on Android / iOS / macOS / JVM; web was unaffected). -
JVM: a transient OS key-vault outage at startup no longer destroys data. A momentarily-unreachable Keychain / Secret Service / DPAPI (locked keychain, login keyring not yet up, SSH / headless launch) used to make KSafe fall back to the software vault and treat it as healthy — which could permanently delete recoverable ciphertext and overwrite the real key. KSafe now distinguishes "vault present but unreachable" from "no vault" and fails safe. Upgrade strongly recommended for JVM / Compose Desktop consumers using OS-backed key custody.
-
A failed write no longer corrupts the rest of the batch — or lies about it. One failing encrypt used to drop every write in the same coalescing window (including unrelated and plain writes), and the failed value kept being served from cache for the rest of the process. Failures are now isolated per entry and the optimistic cache is rolled back.
-
getOrCreateSecretno longer silently rotates an unreadable secret underPLAIN_TEXT. A secret that couldn't be decrypted at cold start (locked device / unavailable vault / corrupt blob) was treated as absent and replaced — orphaning anything encrypted under it (e.g. a SQLCipher DB). It now refuses to rotate and surfaces the condition.
Changed
-
Android: software-DEK fast path for relaxed
DEFAULTencryption. A random AES key (the DEK, sized perKSafeConfig.keySize) is generated once per safe, wrapped (AES-GCM) by the non-exportable Keystore master key (KEK), and persisted as a single reserved entry (__ksafe____DEK____) in the safe's own DataStore — KSafe uses noSharedPreferencesanywhere. It is unwrapped once into an in-process cache, after which every relaxed-DEFAULTencrypt/decrypt is pure-CPU AES-GCM with no TEE round-trip. DEK ciphertext carries a 5-byteMAGIC("KSD1") + VERSIONheader;decryptroutes by it (with a GCM-auth fallback to the legacy TEE path), so no envelope-version bump and no common-code change. A corrupt/mismatched DEK self-heals on the next write;clearAll()wipes it, but deleting an individual key never does.- Security trade-off (Android only): the relaxed-
DEFAULTDEK lives in process memory after first use — the same posture as EncryptedSharedPreferences / Tink and KSafe's own Apple and JVM engines. The KEK never leaves the TEE. Apps that require key material to never reside in memory should useHARDWARE_ISOLATEDorrequireUnlockedDevice = true. - Lazy DEK creation: the DEK is generated on the first real encrypt, not at construction. Prewarm now warms only the wrapping KEK, so an unencrypted-only safe never writes a DEK.
- Security trade-off (Android only): the relaxed-
-
Android DataStore lifecycle is now robust to close-then-recreate on the same file. The factory tracks each datastore's owning scope and awaits its completion (bounded) before reopening — removing the intermittent
IllegalStateException: There are multiple DataStores active for the same fileunder rapid re-init. -
KSafe.protectionInfo(Android) now discloses the in-memory DEK. The custody string notes that relaxedDEFAULTvalues use a TEE-wrapped AES key held in memory, plus arelaxed_default_uses_software_deknote.intendedLevel/effectiveLevelstayHARDWARE_BACKED(custody is still hardware-rooted via the KEK).
Fixed
Core / all platforms
-
Float↔Doubleplain reads silently returned the default (data loss); the full numeric matrix now coerces.KSafeCore.convertStoredValuehadInt↔Longarms but noFloat↔Doubleones, so a key written asFloatand read asDouble(or vice-versa) lost its value on Android / iOS / macOS / JVM (web was unaffected). Widening is exact; narrowing or decimal→integer coerces only when faithfully representable, else falls back to the caller's default. No migration needed. -
A single failing encrypt dropped every other write in the coalesced batch.
awaitAll's fail-fast cancelled siblings, so one throwingencryptSuspenddiscarded every unrelated write in the same ~16 ms window. Encryption is now isolated per entry — a failing encrypt drops only its own key. -
A failed write left its never-persisted value served from cache for the rest of the process. The optimistic cache, protection metadata, and dirty-key set were mutated before commit and never rolled back on failure. Failures now roll back the affected keys' optimistic state (ownership-gated, so a newer write to the same key is never stripped); silent fire-and-forget
putDirectfailures now logSEVERE. -
getOrCreateSecretsilently rotated an existing-but-unreadable secret underPLAIN_TEXT.getKeyInfodecided existence from the in-memory cache, which drops undecryptable entries at cold start — so a present-but-unreadable secret read as absent and got re-minted, orphaning its data. It now also consults the protection-metadata map, which tracks on-disk existence independently of decryptability. -
A write racing an in-flight
clearAll()was unreadable for the rest of the session despite committing. The disk side honored write-after-clear ordering, but the in-memory side cleared the optimistic state unconditionally and never restored it. After commit, a write that is still its key's latest writer now re-asserts its in-memory state atomically. -
Types with primitive-kind custom serializers (
Duration,Uuid, kotlinx-datetime, hand-writtenPrimitiveSerialDescriptor) crashed on plain-mode reads. The write path dispatched on runtime type (JSON-encoding the value) while the read path dispatched on the serializer's descriptor kind — returning raw JSON that the reified cast threw on. The read now takes the primitive fast path only for built-in primitive serializers; everything else round-trips through JSON symmetrically. -
A read racing a write could pin a stale value in the plaintext side cache (
ENCRYPTED_WITH_TIMED_CACHE/LAZY_PLAIN_TEXT). The post-decrypt side-cache write-back could overwrite a fresherput/deletethat landed during the decrypt. The write-back is now compare-and-set guarded against the exact ciphertext it decrypted. -
A transient decrypt failure during flow observation could crash the app (encrypted keys, Android).
getFlow(andasMutableStateFlow/getStateFlow/ Compose live-observe) rethrew a transient failure (e.g. arequireUnlockedDevicekey read while locked) from inside the flow — uncaught on the long-lived observer scopes, crashing the process. Such emissions are now skipped; the flow keeps its last value and recovers on the next decryptable snapshot. -
A write concurrent with the background cache refresh / startup orphan sweep could be silently reverted (all platforms). Both paths now re-check the live in-flight ("dirty") set immediately before mutating — the sweep skips a freshly in-flight key, and the refresh skips merging a stale value (including its routing-metadata syncs, so a protection-changing write keeps its fresh metadata).
-
asMutableStateFlowhad a stale-echo clobber. Setting.valuethen receiving a snapshot emitted before that write committed reverted the StateFlow for all collectors. It now uses a precise guard: a stale observer emission can't override a value written through the StateFlow while propagating, and newer external changes reflect once the flow catches up. -
A corrupt DataStore file now recovers to an empty store instead of crashing or silently defaulting forever. The Android, JVM, and Apple backends had no corruption handler, so a corrupt
.preferences_pbthrewCorruptionExceptionon every read for the process's life. Each backend now quarantines the unreadable file aside (.corrupt-*, recoverable) and continues from an empty store. -
A successful write is no longer reported as failed when the post-commit key cleanup hiccups. A
delete/overwrite committed its storage change, then best-effort deleted the now-unused engine key — a failure there was propagated as a batch failure. The trailing key deletion is now best-effort (a stranded key is reclaimed by the orphan sweep).
Android
- Co-existing
KSafeinstances on the same file could open a second DataStore — or have their store cancelled when another instance closed. The dedup had a non-atomic insert (two concurrent con...
2.1.1
A hardening + diagnostics release. Drop-in upgrade from 2.1.0 — the on-disk format is unchanged.
✨ New & improved
- JVM persists without
jdk.unsupported(#32). Compose Desktop release distributables whose trimmedjlinkruntime omits the module no longer crash or drop writes — KSafe falls back to a software backend (same AES-256-GCM, key in a0700file) and migrates the data forward automatically when you add the module back. - Apple
secureRandomBytesnow sources fromSecRandomCopyBytes— the Security-framework CSPRNG backingSecKey*/ CryptoKit — for AES-256 master-key generation. KSafe.protectionInfois a live diagnostic — recomputed per access, so a JVM mid-process key-vault degrade is visible without restarting the app.KSafe.VERSION/KSafeProtectionInfo.kSafeVersionexpose the linked artifact version at runtime (handy for diagnostic UIs and audit logs).
🐛 Fixes
Data loss & correctness
- Apple: the orphan sweep no longer deletes the v2 master key (the critical fix above).
getOrCreateSecretno longer silently rotates a secret it can't read back (which permanently orphaned e.g. a SQLCipher database) — it now throws instead.- Linux: a locked / unreachable login keyring is no longer mistaken for "key absent" (which let the orphan sweep delete recoverable ciphertext).
- DataStore: switching a key between plain and encrypted no longer leaves a stale opposite-type value behind — including plaintext lingering on disk after a switch to encrypted.
- A
delete+HARDWARE_ISOLATEDputfor the same key inside one write-coalescing window no longer orphans the just-written entry. clearAll()is now serialized with concurrent writes — a write issued just before it can no longer land after the wipe and resurrect data.- JVM
appNamespacenow isolates the data file, not just the OS-vault keys — two desktop apps sharing afileNameno longer clobber one file (see Upgrade notes).
Robustness & security
- iOS biometrics: a cancelled prompt is now aborted (
LAContext.invalidate()) and the continuation guarded — no "already resumed" crash or orphaned system prompt. - Web:
applyBatchis now atomic — aQuotaExceededErrormid-batch can't leave a value written without its metadata. - Apple in-memory cache
clear()is now atomic (a write racingclearAll()could resurrect a cleared entry). - Biometric authorization caching uses a monotonic clock — moving the device clock backward can no longer extend a cached authorization past its window.
- Android: rooted /
userdebugdevices are now detected on modern Android (API 30+), without false-positivinguser-build emulators. - JVM
SecurityCheckerno longer failsKSafe(...)construction on a trimmedjlinkruntime missingjava.management.
🧭 Upgrade notes
- Drop-in from 2.1.0 — the on-disk format is unchanged.
getOrCreateSecretcan now throwIllegalStateExceptionwhen a secret exists but can't be read back (vault unavailable / key invalidated / corrupt), instead of silently minting a new one. Handle it at the call site.- JVM
appNamespace: the on-disk path changes for apps that explicitly set it — data now lives in a per-namespace subdirectory. Existing data is migrated forward automatically (copied, not moved), and apps that don't setappNamespaceare unaffected.
📋 Full, detailed notes (mechanisms, regression tests, per-file specifics): CHANGELOG.md
Full Changelog: 2.1.0...2.1.1
2.1.0
OS-native key custody on JVM and Web, plus a new cross-platform key-protection diagnostic API. Drop-in upgrade — on-disk format is unchanged and existing 2.0 keys migrate on first read.
Highlights
- JVM keys now live in the OS secret store — Windows DPAPI, macOS login Keychain, or Linux Secret Service (libsecret) via JNA — instead of Base64'd next to the ciphertext in the DataStore file.
- Web keys are now non-extractable by the browser. The WebCrypto
CryptoKey(extractable = false) lives in IndexedDB; raw key bytes are no longer recoverable by XSS, extensions, or profile reads. - New
KSafe.protectionInfoAPI — instance-level diagnostic on a universally-ordered scale (SOFTWARE < SANDBOX_PROTECTED < HARDWARE_BACKED < HARDWARE_ISOLATED). Drives startup gates, telemetry, UI badges, runtime feature policy. KSafeKeyInfo.level— per-key audit on the same universal scale. Pair it withprotectionInfofor instance-level and per-key threshold checks.- Regression fix —
get/getFlowwith a nullable default on@Serializableclasses (#31, thanks @DestBro).
Security
- JVM keys now live in the OS secret store. The AES key is protected by Windows DPAPI, the macOS Keychain, or the Linux Secret Service (libsecret) via JNA, instead of being Base64-encoded next to the data in the DataStore file. When no secret store is reachable (headless Linux with no keyring, JNA link failure, …) KSafe falls back to the legacy in-file scheme and logs a one-time security warning. Keys written by KSafe ≤ 2.0 are migrated on first read: copied into the OS store, then removed from the DataStore file only after the OS store is read back and byte-verified (a buggy or again-unavailable keyring that silently no-ops cannot destroy the only copy). Migration is hybrid: lazy per-key on first read plus a one-time best-effort background sweep so a key that's never read again doesn't linger in the file. Opt out with
-Dksafe.jvm.keyVault=software(or envKSAFE_JVM_KEY_VAULT=software). - Web keys are now non-extractable. The browser engine generates an
extractable = falseAES-GCMCryptoKeyand persists the live key object in IndexedDB, instead of exporting the raw key and Base64-ing it intolocalStorage. A legacylocalStoragekey is imported as non-extractable on first access and thelocalStorageentry deleted; previously encrypted data keeps decrypting. Same hybrid lazy + background-sweep migration as JVM.
Added
-
KSafe.protectionInfo: KSafeProtectionInfo— public, cross-platform diagnostic that reports the key custody thisKSafeis actually running with, including any runtime fallback negotiated at construction. Read once at startup:val info = ksafe.protectionInfo // Gate startup check(info.effectiveLevel >= KSafeProtectionLevel.SANDBOX_PROTECTED) // Detect silent fallback (effective < intended) check(info.effectiveLevel >= info.intendedLevel) // Telemetry analytics.log("ksafe_protection", "level" to info.effectiveLevel.name, "custody" to info.custody, "notes" to info.notes.joinToString(","))
Introduces:
KSafeProtectionLevel— universally-ordered scale:SOFTWARE<SANDBOX_PROTECTED<HARDWARE_BACKED<HARDWARE_ISOLATED. One ordinal comparison works across every platform.KSafeProtectionInfo(intendedLevel, effectiveLevel, custody, notes)—effectiveLevelis the actionable field;intendedLevelis the engine's baseline target so consumers can detect when negotiation fell short.custodyis a human-readable description (display, never parse);notesis a list of stable lowercase_snake codes (jvm_os_vault_unavailable,jvm_user_opted_out,apple_secure_enclave_absent).
Per-platform population: Android / Apple report
HARDWARE_BACKEDbaselines (StrongBox / Secure Enclave remain per-write upgrades viaKSafeWriteMode.Encrypted(HARDWARE_ISOLATED)); JVM reportsSANDBOX_PROTECTEDwhen the OS vault is healthy and falls toSOFTWAREwith the appropriatenotescode when the vault self-test fails or the user opts out; Web reportsSANDBOX_PROTECTED(browser-origin sandbox). Full guide and runtime-decision patterns indocs/PROTECTION_INFO.md. -
KSafeKeyInfo.level: KSafeProtectionLevel— per-key audit now reports on the same universal scale asprotectionInfo. Layered checks become possible (gate the engine at startup and refuse to use a specific high-sensitivity key if its own custody didn't meet the bar):val tokenLevel = ksafe.getKeyInfo("auth_token")?.level check(tokenLevel != null && tokenLevel >= KSafeProtectionLevel.HARDWARE_BACKED)
Gives JVM and Web richer granularity than the legacy
KSafeKeyInfo.storage— JVM OS-vault keys and Web browser-origin keys now reportSANDBOX_PROTECTED; only the plaintext-in-file JVM fallback still reportsSOFTWARE. -
JNA dependency on the JVM target (
net.java.dev.jna+jna-platform) for the OS secret-store integration above. JVM / Desktop consumers only.
Deprecated
KSafeKeyInfo.storage: KSafeKeyStorage— superseded byKSafeKeyInfo.level: KSafeProtectionLevel.storagekeeps working with a@Deprecated(ReplaceWith("level"))annotation; planned removal in 3.0.
Fixed
get/getFlowwith a nullable default now deserialize@Serializableclasses correctly (#31, thanks @DestBro). Callingget(key, null as MyType?)orgetFlow(key, null as MyType?)on a@Serializableclass whose first property is a primitive (e.g. a leadingString) threwClassCastException: java.lang.String cannot be cast to MyType— a regression introduced in 2.0.0.primitiveKindOrNullwas descending into the class's first field for a nullable serializer and misclassifying the type as aString, so the raw stored JSON was returned instead of being decoded. A non-null default (get(key, MyType())) was unaffected.
Documentation
- New:
docs/PROTECTION_INFO.md— the newKSafe.protectionInfoAPI: model, per-platform truth table, definednotescodes, and five runtime-decision patterns (gating, tighter re-auth windows, feature disablement, UX honesty banners, intended-vs-effective delta checks). - New:
docs/JVM_PROTECTION.md— platform-by-platform deep dive on the JVM OS vaults (DPAPI / Keychain / libsecret): what each store actually is, threat model per OS, the self-test, the software fallback, the opt-out, and the per-app namespace.
Build
- Suppressed
IncorrectCompileOnlyDependencyWarningfor thecompose-runtimecompileOnlydependency on Native / JS / Wasm targets. The dep is intentionallycompileOnlyso non-Compose consumers (Ktor servers, CLI tools, plain JVM) don't pullcompose-runtimeonto their runtime classpath —@StablehasBINARYretention and no runtime cost. Native / JS / Wasm consumers using:ksafewithout Compose must declarecompose-runtimethemselves to compile against the published klib (accepted trade-off; promoting toapiwould forcecompose-runtimeonto every consumer's runtime classpath).
Upgrade notes
- No source-level changes required for existing 2.0 consumers.
ksafe.put/ksafe.get/by ksafe(0)and all delegates are unchanged. - No on-disk format change. Existing 2.0 ciphertext continues to decrypt; the AES key migrates to the OS-backed custody automatically on first read.
- The legacy
KSafeKeyInfo.storagefield still works. New code should preferlevel(IDE quick-fix offers the replacement).
Full Changelog: 2.0.0...2.1.0
2.0.0
Major release: KMP refactor, new macOS and Kotlin/JS targets, biometrics extracted into its own module, and significant performance work on encrypted reads/writes.
The changes listed below are in addition to the work shipped in 2.0.0-RC1 and 2.0.0-RC2 — see those sections for the complete overview of what the entire 2.0.0 encompasses, including these releases.
Highlights
- Faster encrypted reads and writes. A new per-datastore master-key envelope (v2) eliminates Keystore/Keychain IPC on every encrypted read and write. Biggest wins on stores with many encrypted entries.
- New default memory policy:
LAZY_PLAIN_TEXT. Cheap cold start (no bulk decrypt), O(1) reads after first access. ReplacesENCRYPTEDas the default on Android, iOS, macOS, and JVM. - New platforms. Native macOS (
macosX64,macosArm64) and Kotlin/JS (IR) across all modules. - Biometrics is now its own module.
KSafeBiometricsships as the optional:ksafe-biometricsartifact — apps without biometrics no longer pull inandroidx.biometric. - Migrated to AGP 9.2 + Gradle 9.4. All three modules now use the unified KMP library plugin.
Added
-
KSafeMemoryPolicy.LAZY_PLAIN_TEXT— new default on Android, iOS, macOS, JVM. Keeps ciphertext on cold start, decrypts each key on first read, then caches plaintext for the process lifetime. Web staysPLAIN_TEXT(WebCrypto is async-only). -
Native macOS targets (
macosX64,macosArm64) across:ksafe,:ksafe-compose,:ksafe-biometrics(#26, thanks @tomasjablonskis). Uses the same Keychain + CryptoKit + Secure Enclave path as iOS via sharedappleMain. Intel Macs without a T2 fall back to plain Keychain. -
SecurityCheckershort-circuits on macOS — the iOS jailbreak heuristics would otherwise flag every Mac as rooted. -
macosTestsource set — 73 new tests plus the full commonKSafeTestsuite. -
allowDeviceCredentialFallbackonverifyBiometric/verifyBiometricDirect(#29, thanks @Trucodisparo). New optionalBoolean(defaulttrue). Setfalseto restrict to biometrics only — no PIN/password/pattern fallback. JVM/JS/WasmJS ignore it.val ok = KSafeBiometrics.verifyBiometric( reason = "Confirm payment", allowDeviceCredentialFallback = false )
Fixed
- Compatibility with
dev.whyoleg.cryptography0.6.0 (#27, #30, via #28, thanks @HarukeyUA @chirag38-unity). Resolves the runtimeIrLinkageErroron iOS when a consumer app pulled cryptography-kotlin 0.6.0 transitively. - Critical: Secure Enclave key destruction during 1.x → 2.0 migration (latent in RC1 and RC2). A startup-ordering race could let the orphan-cleanup sweep run against an empty DataStore snapshot and irreversibly destroy Secure Enclave EC private keys.
KSafeCorenow waits for the firstsnapshotFlowemission before migrating; orphan cleanup refuses to delete when DataStore is empty but Keychain has entries. Pinned byKSafeCoreStartupOrderingTest. - macOS biometrics now work on every Mac. Switched to
LAPolicyDeviceOwnerAuthenticationon macOS — falls back to login password or Apple Watch on Macs without Touch ID (Mac mini, many Intel Macs). iOS unchanged. verifyBiometric(suspend) now dispatchesevaluatePolicyon Main on Apple platforms, matchingverifyBiometricDirect.
Changed
- Default memory policy is now
LAZY_PLAIN_TEXT(wasENCRYPTED) on Android, iOS, macOS, JVM. Apps that need ciphertext-at-rest semantics must opt in explicitly withKSafeMemoryPolicy.ENCRYPTEDorENCRYPTED_WITH_TIMED_CACHE. Web's forcedPLAIN_TEXTis unchanged. PLAIN_TEXTis now discouraged in KDoc. Its eager-decrypt-everything cold start is O(n) in encrypted keys and can push first-read latency into ANR territory on large Android stores.LAZY_PLAIN_TEXTmatches its steady-state read performance with a much cheaper start.PLAIN_TEXTis still supported.IosKeychainEncryption→AppleKeychainEncryption(and surroundingIos*→Apple*renames) to reflect shared iOS + macOS use.@PublishedApi internal; only consumer code that references these symbols directly is affected.- macOS Keychain prompt — doc-only. Factory KDoc now flags that unsandboxed Mac apps see a system password prompt on first Keychain access (suppressed by signing with a Keychain access group entitlement).
Performance
- v2 envelope routes every
KSafeProtection.DEFAULTencrypted write through one of two AES-256 master keys per datastore (a relaxed-accessibility variant and arequireUnlockedDevice = truevariant), unwrapped once at construction and cached in-process. After warm-up, encrypt and decrypt are pure-CPU AES-GCM — no Keystore/Keychain IPC for the lifetime of the process.KSafeProtection.HARDWARE_ISOLATEDwrites still get a per-entry key (StrongBox / Secure Enclave isolation is the point). Existingv1and legacy entries continue to read through the per-entry path unchanged — no migration, no rewrite. Entries written by 2.0 cannot be read by 1.x. - Parallel batch encrypt — encrypted writes in a batch deduplicate by key and run concurrently with a
Semaphore(8)cap.ENCRYPTEDmemory policy no longer pays a write-time penalty overPLAIN_TEXT. - Parallel cold-start decrypt —
updateCacheandcleanupOrphanedCiphertextnow decrypt concurrently. Cold-start time on a 1500-key encrypted store drops from ~27 ms to under 1 ms. detectProtectionshort-circuit trusts 2.0 metadata authoritatively when present, saving an allocation and a map lookup per unencrypted read.AndroidKeystoreEncryptionmicro-optimisations — lazy companion-levelKeyStore, zero-copy GCM decrypt, single-allocation encrypt buffer, collapsedcontainsAlias+getKey/deleteEntryIPC round-trips.AppleKeychainEncryptionkey-byte cache — repeat encrypt/decrypt on the same key short-circuits bothSecItemCopyMatchingand the SESecKeyCreateDecryptedDataECIES unwrap. Brings Apple in line with the per-alias caches Android and JVM already had.- Suspend
put/deletenow go through the write coalescer. 500 concurrent suspend writes show 5–27× lower per-op latency depending on encryption mode. hasAnyEncryptedKeyatomic flag lets plain-only stores skip theprotectionMaplookup on every read.- Refreshed benchmarks in
docs/BENCHMARKS.md— median of 4 runs on a Galaxy S24. Suspend-API cells now exercise concurrent coroutines, reflecting real-world usage.
Tooling
- AGP
8.13.1→9.2.1, Gradle8.14.4→9.4.1.:ksafemigrated fromcom.android.librarytocom.android.kotlin.multiplatform.library, aligning all three modules.androidInstrumentedTestsource set renamed toandroidDeviceTest. Removed obsoletegradle.propertiesflags now defaulted in AGP 9 (android.useAndroidX,android.nonTransitiveRClass,kotlin.kmp.isolated-projects.support).
Validation
:ksafe:macosArm64Test— 118 tests (73 new + 45 common).:ksafe:iosSimulatorArm64Test— 127 tests, no regression.- Android instrumented tests — 64/64 on emulator and physical Galaxy S24.
- All
linkDebugFramework*and cross-target compile tasks pass cleanly. - End-to-end exercised via KSafeDemo on all six targets.
Full Changelog: 2.0.0-RC2...2.0.0
2.0.0-RC2
Cumulative changes over 2.0.0-RC1. Four purely additive public APIs and a hardening pass on cancellation handling. No breakage — no behavioural change for callers who don't opt in.
Added
rememberKSafeState — persistent composable-body state
A new :ksafe-compose helper that brings rememberSaveable ergonomics to KSafe: composable-local state that survives app restarts, not just configuration changes.
@Composable
fun TabbedScreen(ksafe: KSafe) {
var currentlySelectedIndex by ksafe.rememberKSafeState(0) // key = "currentlySelectedIndex"
var draftMessage by ksafe.rememberKSafeState("") // key = "draftMessage"
var explicit by ksafe.rememberKSafeState("", key = "screen.draft")
}Built for state that naturally lives in the composable — bottom-tab indexes, scroll positions, expanded sections, draft input, sort-order toggles — where routing through a ViewModel would be overkill. Auto-keys from the property name (same convention as ksafe.mutableStateOf).
| Use case | API |
|---|---|
| ViewModel / class property | var x by ksafe.mutableStateOf(default) (unchanged) |
| Composable-body local state | var x by ksafe.rememberKSafeState(default) (new) |
The state is materialised inside remember(key) and the self-heal / external-change collector run in a LaunchedEffect, so leaving the composition cancels everything automatically. No detached coroutines — safe at recomposition rate. Defaults to KSafeWriteMode.Plain; pass mode = KSafeWriteMode.Encrypted(...) to opt in.
:ksafe-compose now applies the Compose compiler plugin so the @Composable provideDelegate codegen works. Published artifact shape is unchanged; consumers need no new setup.
asWritableFlow — observable + writable in one declaration
A cold Flow<T> you can also write to via set(value). Collapses the previous two-binding pattern into one:
// Before — two properties, keys kept in sync manually
val themeMode: Flow<ThemeMode> by ksafe.asFlow(ThemeMode.DEVICE, key = "themeMode")
private var themeModeValue: ThemeMode by ksafe(ThemeMode.DEVICE, key = "themeMode")
fun setThemeMode(mode: ThemeMode) { themeModeValue = mode }
// After
class SettingsRepository(ksafe: KSafe) {
val themeMode: WritableKSafeFlow<ThemeMode> by ksafe.asWritableFlow(ThemeMode.DEVICE)
fun setThemeMode(mode: ThemeMode) { themeMode.set(mode) }
}WritableKSafeFlow<T> : Flow<T>, so observe-only consumers can take a plain Flow<T>. No synchronous getter — collection only — which keeps the contract identical on every platform, including web cold-start.
| API | Read | Write | Scope | Hot/Cold |
|---|---|---|---|---|
asFlow |
✅ flow | — | none | cold |
asStateFlow |
✅ flow + .value |
— | required | hot |
asWritableFlow (new) |
✅ flow | ✅ set() |
none | cold |
asMutableStateFlow |
✅ flow + .value |
✅ .value = |
required | hot |
KSafe.close() — optional instance disposal
Releases the long-running coroutine infrastructure a KSafe instance owns: write-channel consumer, snapshot collector, and the DataStore scope (file watcher, write coordinator, cached Preferences MutableStateFlow). On Android, also evicts the per-file entry from the process-static DataStore cache when this instance owns it.
val ksafe = KSafe(fileName = "session_$userId")
// ... use it ...
ksafe.close()Optional and almost always unnecessary — process-lifetime singletons don't need it. Call close() when you re-create KSafe mid-process:
- Account/profile switching that changes the
fileName - Long-running JVM services that construct a fresh instance per session/tenant/request
- Dev-time hot-reload that rebuilds the DI graph
Withoutclose(), abandoned instances pin their DataStore scope onDispatchers.IO. Idempotent; afterclose()discard the reference. See docs/SETUP.md for lifecycle scenarios.
Internal: observeFromStorage
Consolidates the previously-duplicated branch logic at mutableStateOf's call site into a single @PublishedApi internal suspend helper. Both mutableStateOf and rememberKSafeState route through it. No public-API or behavioural change for mutableStateOf callers.
Fixed
CancellationException no longer swallowed in KSafeCore and :ksafe-compose
Several runCatching { … } and broad catch (Throwable) blocks in coroutine-owning paths used to swallow CancellationException along with everything else. Structural shutdown still worked, but cancel()/close() mid-batch produced spurious "KSafe: processBatch failed, dropping N writes: …" log lines on every clean teardown, and kotlinx-coroutines-test could see one extra batch run after cancel(), occasionally surfacing as UncaughtExceptionsBeforeTest in the next test.
Full Changelog: 2.0.0-RC1...2.0.0-RC2
2.0.0-RC1
Release candidate for the 2.0 line. Major internal refactor; new standalone :ksafe-biometrics module; Kotlin/JS (IR) target alongside the existing wasmJs target.
Release candidate. On-disk format is stable and the public storage API is source-compatible with 1.8.x. Please file feedback before 2.0.0 final.
Install
dependencies {
implementation("eu.anifantakis:ksafe:2.0.0-RC1")
// Compose property delegate (var x by ksafe.mutableStateOf(...))
implementation("eu.anifantakis:ksafe-compose:2.0.0-RC1")
// Biometric verification — now its own artifact (see Breaking changes)
implementation("eu.anifantakis:ksafe-biometrics:2.0.0-RC1")
}Targets: Android, iOS (x64 / arm64 / simulatorArm64), JVM, Kotlin/JS (IR), Kotlin/WASM.
New Features - Updates
Browser support beyond WasmGC — new Kotlin/JS (IR) target
:ksafe, :ksafe-compose, and :ksafe-biometrics now publish a Kotlin/JS (IR) artifact alongside the existing wasmJs artifact. Projects that build for the legacy JS toolchain — or that need to ship to browsers without WasmGC (anything older than Chrome 119 / Firefox 120 / Safari 18) — are no longer dead-ended.
kotlin {
js(IR) { browser() } // new
@OptIn(ExperimentalWasmDsl::class)
wasmJs { browser() } // existing
}Behaviour is identical to the wasmJs target — same key layout in localStorage, same AES-256-GCM via WebCrypto, same async-only crypto contract — so a project can switch between the two targets and read the same data back.
Custom storage directory (#25, thanks @DeStilleGast)
KSafe(...) now accepts an optional override for where the underlying DataStore file lives — useful for noBackupFilesDir, XDG-compliant locations, test isolation, etc.
// JVM
KSafe(fileName = "settings", baseDir = File(xdgDataHome, "myapp"))
// Android
KSafe(context, fileName = "vault", baseDir = File(context.noBackupFilesDir, "ksafe"))
// iOS
KSafe(fileName = "vault", directory = "/some/path")POSIX 0700 permissions are now also applied to user-supplied paths on JVM (previously skipped), and clearAll() now correctly cleans up files under the custom path on JVM (previously hardcoded to home dir).
iOS default storage moved to Application Support with automatic migration
Pre-2.0 iOS stored the DataStore in NSDocumentDirectory — user-visible and iCloud-syncable by default, both wrong defaults for KSafe content. 2.0 moves the default to NSApplicationSupportDirectory.
Apps upgrading from 1.x do not need to change any code. On first launch under 2.0, KSafe checks for a legacy file at the old Documents path and moves it. The migration is idempotent and best-effort (a failed move logs a warning and leaves the legacy file in place; the app can recover by passing directory = "<old Documents path>").
KSafe data on iOS is effectively device-local regardless of backup state — encryption keys live in the Keychain with …ThisDeviceOnly accessibility, and HARDWARE_ISOLATED keys never leave the Secure Enclave. Even if the DataStore file is included in an iCloud Backup, its encrypted bytes are undecryptable on a restored device.
Single shared KSafeCore orchestrator
Everything that was duplicated across the four platform shells — hot cache, write coalescer, protection-metadata classifier, orphan-ciphertext cleanup, raw get/put/delete/getFlow plumbing — now lives in one KSafeCore class in commonMain.
| File | Before 2.0 | After 2.0 |
|---|---|---|
commonMain (KSafe + KSafeCore + storage interface + concurrency shims) |
~500 | ~1,500 |
KSafe.jvm.kt |
1,360 | 212 |
KSafe.android.kt |
1,584 | 176 |
KSafe.ios.kt |
1,938 | 225 |
KSafe.web.kt |
1,052 | 128 |
| Platform shells, total | 5,934 | 741 |
KSafe itself is now a regular common class — no more expect/actual — with all storage methods (including the inline reified ones) defined exactly once. Bug fixes ship once instead of four times. The consumer call site is unchanged: KSafe(...) on every platform is still spelled the same way.
Breaking changes
Biometric verification moved to its own module (#14, thanks @Coding-Meet)
verifyBiometric, verifyBiometricDirect, clearBiometricAuth, BiometricAuthorizationDuration, and BiometricHelper no longer live on KSafe. They belong to a new static KSafeBiometrics API published as a separate, optional artifact.
// build.gradle.kts
implementation("eu.anifantakis:ksafe-biometrics:2.0.0-RC1"):ksafe-biometrics has zero dependency on :ksafe — apps that only need biometric verification don't pull in the storage library, and apps that don't need biometrics no longer pay for the androidx.biometric / androidx.fragment transitive deps that :ksafe used to drag in.
KSafeBiometrics is a Kotlin object — no DI wiring, no Context parameter, no Application.onCreate init. Android auto-initializes via a ContentProvider declared in the merged manifest (the same pattern WorkManager / Firebase / AppCompat use). The call shape is identical on every target:
val ok = KSafeBiometrics.verifyBiometric("Authenticate")
KSafeBiometrics.verifyBiometricDirect("Authenticate") { success -> /* ... */ }
KSafeBiometrics.clearBiometricAuth()Migration:
// Before (1.x)
import eu.anifantakis.lib.ksafe.BiometricAuthorizationDuration
ksafe.verifyBiometricDirect(reason, BiometricAuthorizationDuration(60_000L)) { ok -> }
// After (2.0)
import eu.anifantakis.lib.ksafe.biometrics.KSafeBiometrics
import eu.anifantakis.lib.ksafe.biometrics.BiometricAuthorizationDuration
KSafeBiometrics.verifyBiometricDirect(reason, BiometricAuthorizationDuration(60_000L)) { ok -> }Method names and signatures are preserved; only the receiver and import paths change. BiometricHelper.confirmationRequired and BiometricHelper.promptTitle continue to be configured the same way, just imported from the new package. Apps that don't use biometric verification need no changes.
iOS default file path changed (auto-migrated)
See iOS default storage moved to Application Support under Highlights. No code change required for upgraders; the library moves the legacy file on first launch.
Fixes
Serializer-kind dispatch in convertStoredValue
Two latent bugs shared a root cause — the read path dispatched on the runtime class of the defaultValue parameter (when (defaultValue) { is Int -> …, is Long -> … }):
- Kotlin/JS: Float / Double reads collapsed into the Int branch.
FloatandDoubleshare a single runtime representation withInton JS (0f is Intreturnstrue), so float / double reads routed into the Int branch and returned the default. - Nullable-typed reads with a
nulldefault lost stored primitives. Forget<Int?>("counter", null), nois Xbranch matched (becausenulldoesn't satisfyis Int), and the path returnednullregardless of whether a value was stored.
Dispatch now runs through the serializer's PrimitiveKind, which preserves the declared type regardless of how the runtime represents the value or how the default is typed. One code path, uniform across every platform.
Transient keystore decrypt errors now propagate on every platform
Pre-2.0 only Android's read path re-threw "device is locked" / "Keystore" errors instead of silently returning defaultValue; iOS and JVM swallowed the same errors. The check now runs in KSafeCore so a locked device (or analogous iOS Keychain error) reliably surfaces to the caller for retry handling.
iOS Keychain orphan sweep strengthened
The startup Keychain sweep — which removes Keychain entries whose DataStore counterpart is gone — was refactored into a standalone, unit-testable function and now covers two item classes: generic-password items (plain AES keys and SE-wrapped blobs) and kSecClassKey EC private keys (the SE-held ECIES keys used for HARDWARE_ISOLATED writes). The second pass catches SE keys that exist without a matching generic-password item — e.g. after a crash between SE-key creation and wrapped-AES-key storage.
Internal changes (no consumer impact)
- New
KSafePlatformStorageinterface; sharedDataStoreStorageadapter for Android / iOS / JVM lives in a newdatastoreMainintermediate source set; web keeps its ownLocalStorageStorageadapter forlocalStorage. KSafeEncryptiongainedencryptSuspend/decryptSuspend/deleteKeySuspend;WebSoftwareEncryptionoverrides them with real WebCrypto calls (the blocking variants throw — WebCrypto is async-only).- The bulk of the previous
wasmJsMainimplementation moved into a sharedwebMainsource set used by bothwasmJsandjs(IR). The fullKSafeTestsuite now runs on both targets via a sharedwebTest/WebKSafeTest.kt. - New cross-type migration tests (
testCrossTypeIntToLongPlain,testCrossTypeLongToIntPlainOutOfRange,testSequentialTypeMigrationIntThenLong, etc.) lock in the safe-narrow / refuse-truncate / read-after-rewrite contracts that were implicit in pre-refactor code. - New
WebInteropSmokeTestruns on both wasmJs and js and asserts the per-target actuals:localStorageround-trip, enumeration,currentTimeMillisWeb()plausibility,secureRandomBytes()non-determinism. - Internal types (
KSafeCore,KSafePlatformStorage,KSafeEncryption,KeySafeMetadataManager,SecurityChecker,KSafeSecureRandom, the per-platform engines, allexpect/actualshims) moved toeu.anifantakis.lib.ksafe.internal. Public-facing types stay at the root package — no consumer imports break.
Upgrade notes
- On-disk format is unchanged. E...
1.8.1
Added
Android: BiometricHelper.confirmationRequired (#11 — thanks @HansHolz09)
Added a confirmationRequired: Boolean = true property on BiometricHelper that wraps BiometricPrompt.PromptInfo.Builder.setConfirmationRequired(...). Keep the default for sensitive actions — the prompt only resolves after an explicit user confirmation. Set to false for passive flows where the biometric match itself should be sufficient.
BiometricHelper.confirmationRequired = false // allow passive face-unlockNote: this flag only affects weak/passive biometric modalities (e.g. face). For BIOMETRIC_STRONG modalities like fingerprint, the physical action is the confirmation and this flag has no effect.
Fixed
iOS: Keychain NSString Memory Leak on Background Threads (#22)
Fixed a memory leak in IosKeychainEncryption where Kotlin → NSString bridging conversions (e.g. inside CFBridgingRetain(keyId)) accumulated indefinitely when keychain operations ran on coroutine worker threads. The root cause is that Kotlin/Native emits autorelease-convention NSString allocations for string bridging, and Dispatchers.Default / SKIE-bridged Swift async worker threads do not have an ambient ObjC autorelease pool to drain them. Over time this surfaced as continuously growing memory in Instruments, dominated by Kotlin_ObjCExport_CreateRetainedNSStringFromKString allocations attributed to IosKeychainEncryption#getExistingKeychainKey and related paths.
The fix wraps the memScoped { ... } body of every keychain-touching internal method in kotlinx.cinterop.autoreleasepool { ... } so autoreleased bridged NSStrings drain promptly regardless of which thread the caller is on. No public API changes.
Affected methods (all internal): createSecureEnclaveKey, getSecureEnclaveKey, deleteSecureEnclaveKey, updateSecureEnclaveKeyAccessibility, getExistingKeychainKeyRaw, getExistingKeychainKeyPlain, getOrCreateKeychainKeyPlain, storeInKeychain, updateKeychainItemAccessibility, deleteFromKeychain.
A regression test (IosKeychainEncryptionLeakTest) was added that runs 5,000 keychain operations on Dispatchers.Default and asserts peak RSS growth stays under 2 MB via getrusage(RUSAGE_SELF). Pre-fix the test reports ~7 MB of growth; post-fix it stays within allocator slack.
Full Changelog: 1.8.0...1.8.1
1.8.0
Added
Cryptographic Utilities: secureRandomBytes & getOrCreateSecret
secureRandomBytes(size: Int): ByteArray — A cross-platform cryptographically secure random byte generator, delegating to each platform's strongest CSPRNG (java.security.SecureRandom on Android/JVM, arc4random_buf on iOS, crypto.getRandomValues() on WASM). This is now also used internally by KSafe's own encryption engines for IV and key generation.
val nonce = secureRandomBytes(16)
val aesKey = secureRandomBytes(32)KSafe.getOrCreateSecret(key, size, protection, requireUnlockedDevice): ByteArray — A suspend extension that generates a cryptographically secure random secret on first call and retrieves it on subsequent calls. Stored with hardware-backed encryption (HARDWARE_ISOLATED by default). Ideal for database encryption passphrases, API signing keys, HMAC keys, or any persistent secret.
// Database passphrase — one line, hardware-backed, generated once
val passphrase = ksafe.getOrCreateSecret("main.db")
// Custom size + protection
val apiKey = ksafe.getOrCreateSecret("api_key", size = 64)Flow & StateFlow Property Delegates (#20)
Since v1.0.0, KSafe offered var counter by ksafe(0) (plain delegates) and var counter by ksafe.mutableStateOf(0) (Compose state). Version 1.8.0 adds MutableStateFlow delegates (asMutableStateFlow) as a drop-in replacement for the standard _state/state pattern, read-only flow delegates (asStateFlow / asFlow), and cross-screen sync via mutableStateOf(scope=). All new delegates derive their storage key from the property name (with an optional key override), staying consistent with the existing invoke() delegate — and the explicit-key getStateFlow() / getFlow() APIs remain fully supported.
Core Module — flow delegates
1. asMutableStateFlow (Read / Write)
Implements the full MutableStateFlow interface — all standard atomic operations work out of the box, persisting to encrypted storage instantly.
// Standard Kotlin pattern
private val _state = MutableStateFlow(MoviesListState())
val state = _state.asStateFlow()
// KSafe equivalent — same pattern, but persisted + reactive to external changes
private val _state by kSafe.asMutableStateFlow(MoviesListState(), viewModelScope)
val state = _state.asStateFlow()@Serializable
data class MoviesListState(
val loading: Boolean = false,
val movies: List<Movie> = emptyList(),
val error: String? = null
)
class MoviesViewModel(private val kSafe: KSafe, private val api: MoviesApi) : ViewModel() {
// Acts exactly like a standard MutableStateFlow, but fully persisted
private val _state by kSafe.asMutableStateFlow(MoviesListState(), viewModelScope)
val state = _state.asStateFlow()
fun loadMovies() {
// .update {} persists securely (uses compareAndSet internally)
_state.update { it.copy(loading = true) }
viewModelScope.launch {
try {
val movies = api.getMovies()
// .value = ... also persists instantly
_state.value = _state.value.copy(loading = false, movies = movies)
} catch (e: Exception) {
_state.update { it.copy(loading = false, error = e.message) }
}
}
}
}
@Composable
fun MoviesScreen(viewModel: MoviesViewModel) {
val state by viewModel.state.collectAsState()
when {
state.loading -> CircularProgressIndicator()
state.error != null -> Text("Error: ${state.error}")
else -> LazyColumn {
items(state.movies) { movie -> MovieItem(movie) }
}
}
}2. asStateFlow & asFlow (Read-Only)
If you only need to read data (or update it manually via kSafe.put()), you can use read-only flow delegates.
class SettingsViewModel(private val kSafe: KSafe) : ViewModel() {
// Hot flow tied to viewModelScope
val username: StateFlow<String> by kSafe.asStateFlow("Guest", viewModelScope)
// Cold flow
val darkMode: Flow<Boolean> by kSafe.asFlow(defaultValue = false)
// Optional: explicitly override the storage key
val theme: Flow<String> by kSafe.asFlow(defaultValue = "light", key = "app_theme")
fun onNameChanged(name: String) {
viewModelScope.launch { kSafe.put("username", name) }
}
}Compose Module — cross-screen reactivity
The existing mutableStateOf now accepts an optional scope parameter.
Without scope (existing behavior) — the state reads from cache at init and persists on write, but it's isolated. If another ViewModel or a background put() writes to the same key, this state won't update until the ViewModel is recreated.
With scope — the state continuously observes the underlying flow. Changes from any source (another screen, another ViewModel, a background coroutine) are reflected in real-time. No manual refreshes or event buses required.
If you only read/write from a single ViewModel, both behave identically. The
scopeparameter matters when multiple writers exist for the same key.
// Dashboard Screen — auto-reflects changes made from other screens
class DashboardViewModel(private val kSafe: KSafe) : ViewModel() {
var username by kSafe.mutableStateOf("Guest", scope = viewModelScope)
var notificationsEnabled by kSafe.mutableStateOf(false, scope = viewModelScope)
}
// Settings Screen — writes to the same KSafe instance
class SettingsViewModel(private val kSafe: KSafe) : ViewModel() {
var username by kSafe.mutableStateOf("Guest", scope = viewModelScope)
var notificationsEnabled by kSafe.mutableStateOf(false, scope = viewModelScope)
}
// When SettingsScreen writes, DashboardScreen auto-updates — no manual refresh
@Composable
fun DashboardScreen(viewModel: DashboardViewModel) {
Text("Welcome, ${viewModel.username}")
if (viewModel.notificationsEnabled) Text("Notifications ON")
}
@Composable
fun SettingsScreen(viewModel: SettingsViewModel) {
TextField(value = viewModel.username, onValueChange = { viewModel.username = it })
Switch(
checked = viewModel.notificationsEnabled,
onCheckedChange = { viewModel.notificationsEnabled = it }
)
}API summary
| Module | Function | Type | Returns |
|---|---|---|---|
| core | asFlow(defaultValue, key?) |
Read-only | Flow<T> delegate |
| core | asStateFlow(defaultValue, scope, key?) |
Read-only | StateFlow<T> delegate |
| core | asMutableStateFlow(defaultValue, scope, key?, mode?) |
Read/write + Reactive | MutableStateFlow<T> delegate |
| compose | mutableStateOf(..., scope?) |
Read/write + Reactive | MutableState<T> w/ flow observation |
Full Changelog: 1.7.1...1.8.0
1.7.1
Added
-
Custom JSON Serialization (#19)
KSafeConfig now accepts a json parameter — a fully configured Json instance used for all user-payload serialization. This enables support for @Contextual types (e.g., UUID, Instant, BigDecimal) and custom SerializersModule registration.
val customJson = Json {
ignoreUnknownKeys = true
serializersModule = SerializersModule {
contextual(UUIDSerializer)
contextual(InstantSerializer)
}
}
val ksafe = KSafe(
config = KSafeConfig(json = customJson)
)- Serializers are registered once at the instance level and apply to all operations (
putDirect,getDirect,put,get,getFlow, delegates) - Internal metadata serialization is unaffected — it uses its own private codec
- Default remains
Json { ignoreUnknownKeys = true }viaKSafeDefaults.json— no changes needed for existing code kotlinx-serialization-jsonis declared in the library as a transitive dependency (apiscope) — no need to add it manually in your project- Note: Changing the
Jsonconfiguration for an existingfileNamenamespace may make previously stored non-primitive values unreadable
Sample Usage
Define custom serializers (I add two to show approaching n custom fields)
object UUIDSerializer : KSerializer<UUID> {
override val descriptor = PrimitiveSerialDescriptor("UUID", PrimitiveKind.STRING)
override fun serialize(encoder: Encoder, value: UUID) = encoder.encodeString(value.toString())
override fun deserialize(decoder: Decoder): UUID = UUID.fromString(decoder.decodeString())
}
object InstantSerializer : KSerializer<Instant> {
override val descriptor = PrimitiveSerialDescriptor("Instant", PrimitiveKind.STRING)
override fun serialize(encoder: Encoder, value: Instant) = encoder.encodeString(value.toString())
override fun deserialize(decoder: Decoder): Instant = Instant.parse(decoder.decodeString())
}Build a Json instance and register all your serializers in one place
val customJson = Json {
ignoreUnknownKeys = true
serializersModule = SerializersModule {
contextual(UUIDSerializer)
contextual(InstantSerializer)
// add as many as you need
}
}Pass it via KSafeConfig
val ksafe = KSafe(
context = context, // Android; omit on JVM/iOS/WASM
config = KSafeConfig(json = customJson)
)Use @contextual types directly, so no extra work at the call site
@Serializable
data class UserProfile(
val name: String,
@Contextual val id: UUID,
@Contextual val createdAt: Instant
)
ksafe.putDirect("profile", UserProfile("Alice", UUID.randomUUID(), Instant.now()))
val profile: UserProfile = ksafe.getDirect("profile", defaultProfile)Fixed
-
WASM: Encrypted
mutableStateOfDelegates Return Defaults on Page Reload
Fixed a race condition on WASM where mutableStateOf Compose delegates could return the default value instead of the persisted encrypted value after a browser refresh. This occurred because WASM's WebCrypto decryption is async-only — if a KSafe instance was created and immediately read from in the same synchronous frame (e.g., via Koin lazy singleton injection into a ViewModel), the cache hadn't loaded yet.
The fix adds reactive self-healing to KSafeComposeState: when getDirect returns the default, a lightweight coroutine observes getFlow and updates the Compose state when the real decrypted value arrives. A userHasWritten guard ensures user writes are never overwritten by late-arriving cache data.
This bug was latent since WASM support was added but only surfaced when using multiple KSafe instances (e.g., a second instance with custom JSON serialization), where the second instance had no head start for its async cache loading.
-
Inline Bytecode Bloat (#16)
Reduced bytecode generated at each KSafe call site by extracting non-reified logic from inline functions into @PublishedApi internal helpers. Previously, every getDirect/putDirect delegate expansion could produce thousands of bytecode instructions because the entire function body was inlined. Now only the serializer<T>() call is inlined; the rest is a regular function call to the *Raw variant.
-
Relaxed
fileNameValidation
The fileName parameter now accepts lowercase letters, digits, and underscores (must start with a letter). Previously only [a-z]+ was allowed, which was unnecessarily restrictive. The regex is now [a-z][a-z0-9_]* across all platforms. Dots, slashes, and uppercase remain forbidden to prevent path traversal and case-sensitivity issues.
Full Changelog: 1.7.0...1.7.1