Skip to content

Releases: ioannisa/KSafe

2.1.3

Choose a tag to compare

@ioannisa ioannisa released this 17 Jun 13:02

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 with requireUnlockedDevice = true could 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: KSafeCore now evaluates metadata before hitting the plaintextCache or memoryCache, forcing a native read if an unlocked device is required.
    • Android Engine: AndroidKeystoreEncryption now bypasses its software DEK cache and queries the Keystore hardware directly, restoring the native UserNotAuthenticatedException on locked devices.
    • iOS/macOS Engine: AppleKeychainEncryption now bypasses its keyBytesCache and queries the Keychain directly, strictly enforcing the kSecAttrAccessibleWhenUnlockedThisDeviceOnly policy.
      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.

Full Changelog: 2.1.2...2.1.3

2.1.2

Choose a tag to compare

@ioannisa ioannisa released this 13 Jun 13:41

Highlights

  • Android ENCRYPTED-memory reads are now ~570× faster on real hardware. Before 2.1.2, every ENCRYPTED-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, ENCRYPTED Direct 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_ISOLATED and strict requireUnlockedDevice are unchanged. Both keep their key in the TEE and decrypt there on every op — the DEK fast path applies only to the relaxed DEFAULT master (usable while the device is locked anyway), so no security property is lost.

  • No migration, no data loss. DEK ciphertext is self-describing (MAGIC || VERSION header), 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/Double matrix coerces when the value is faithfully representable. Previously Float↔Double returned 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.

  • getOrCreateSecret no longer silently rotates an unreadable secret under PLAIN_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 DEFAULT encryption. A random AES key (the DEK, sized per KSafeConfig.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 no SharedPreferences anywhere. It is unwrapped once into an in-process cache, after which every relaxed-DEFAULT encrypt/decrypt is pure-CPU AES-GCM with no TEE round-trip. DEK ciphertext carries a 5-byte MAGIC("KSD1") + VERSION header; decrypt routes 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-DEFAULT DEK 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 use HARDWARE_ISOLATED or requireUnlockedDevice = 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.
  • 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 file under rapid re-init.

  • KSafe.protectionInfo (Android) now discloses the in-memory DEK. The custody string notes that relaxed DEFAULT values use a TEE-wrapped AES key held in memory, plus a relaxed_default_uses_software_dek note. intendedLevel / effectiveLevel stay HARDWARE_BACKED (custody is still hardware-rooted via the KEK).


Fixed

Core / all platforms

  • FloatDouble plain reads silently returned the default (data loss); the full numeric matrix now coerces. KSafeCore.convertStoredValue had Int↔Long arms but no Float↔Double ones, so a key written as Float and read as Double (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 throwing encryptSuspend discarded 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 putDirect failures now log SEVERE.

  • getOrCreateSecret silently rotated an existing-but-unreadable secret under PLAIN_TEXT. getKeyInfo decided 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-written PrimitiveSerialDescriptor) 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 fresher put/delete that 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 (and asMutableStateFlow / getStateFlow / Compose live-observe) rethrew a transient failure (e.g. a requireUnlockedDevice key 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).

  • asMutableStateFlow had a stale-echo clobber. Setting .value then 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_pb threw CorruptionException on 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 KSafe instances 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...
Read more

2.1.1

Choose a tag to compare

@ioannisa ioannisa released this 02 Jun 23:10

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 trimmed jlink runtime omits the module no longer crash or drop writes — KSafe falls back to a software backend (same AES-256-GCM, key in a 0700 file) and migrates the data forward automatically when you add the module back.
  • Apple secureRandomBytes now sources from SecRandomCopyBytes — the Security-framework CSPRNG backing SecKey* / CryptoKit — for AES-256 master-key generation.
  • KSafe.protectionInfo is a live diagnostic — recomputed per access, so a JVM mid-process key-vault degrade is visible without restarting the app.
  • KSafe.VERSION / KSafeProtectionInfo.kSafeVersion expose 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).
  • getOrCreateSecret no 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_ISOLATED put for 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 appNamespace now isolates the data file, not just the OS-vault keys — two desktop apps sharing a fileName no 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: applyBatch is now atomic — a QuotaExceededError mid-batch can't leave a value written without its metadata.
  • Apple in-memory cache clear() is now atomic (a write racing clearAll() 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 / userdebug devices are now detected on modern Android (API 30+), without false-positiving user-build emulators.
  • JVM SecurityChecker no longer fails KSafe(...) construction on a trimmed jlink runtime missing java.management.

🧭 Upgrade notes

  • Drop-in from 2.1.0 — the on-disk format is unchanged.
  • getOrCreateSecret can now throw IllegalStateException when 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 set appNamespace are unaffected.

📋 Full, detailed notes (mechanisms, regression tests, per-file specifics): CHANGELOG.md


Full Changelog: 2.1.0...2.1.1

2.1.0

Choose a tag to compare

@ioannisa ioannisa released this 24 May 15:26

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.protectionInfo API — 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 with protectionInfo for instance-level and per-key threshold checks.
  • Regression fixget / getFlow with a nullable default on @Serializable classes (#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 env KSAFE_JVM_KEY_VAULT=software).
  • Web keys are now non-extractable. The browser engine generates an extractable = false AES-GCM CryptoKey and persists the live key object in IndexedDB, instead of exporting the raw key and Base64-ing it into localStorage. A legacy localStorage key is imported as non-extractable on first access and the localStorage entry 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 this KSafe is 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)effectiveLevel is the actionable field; intendedLevel is the engine's baseline target so consumers can detect when negotiation fell short. custody is a human-readable description (display, never parse); notes is 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_BACKED baselines (StrongBox / Secure Enclave remain per-write upgrades via KSafeWriteMode.Encrypted(HARDWARE_ISOLATED)); JVM reports SANDBOX_PROTECTED when the OS vault is healthy and falls to SOFTWARE with the appropriate notes code when the vault self-test fails or the user opts out; Web reports SANDBOX_PROTECTED (browser-origin sandbox). Full guide and runtime-decision patterns in docs/PROTECTION_INFO.md.

  • KSafeKeyInfo.level: KSafeProtectionLevel — per-key audit now reports on the same universal scale as protectionInfo. 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 report SANDBOX_PROTECTED; only the plaintext-in-file JVM fallback still reports SOFTWARE.

  • 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 by KSafeKeyInfo.level: KSafeProtectionLevel. storage keeps working with a @Deprecated(ReplaceWith("level")) annotation; planned removal in 3.0.

Fixed

  • get / getFlow with a nullable default now deserialize @Serializable classes correctly (#31, thanks @DestBro). Calling get(key, null as MyType?) or getFlow(key, null as MyType?) on a @Serializable class whose first property is a primitive (e.g. a leading String) threw ClassCastException: java.lang.String cannot be cast to MyType — a regression introduced in 2.0.0. primitiveKindOrNull was descending into the class's first field for a nullable serializer and misclassifying the type as a String, 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 new KSafe.protectionInfo API: model, per-platform truth table, defined notes codes, 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 IncorrectCompileOnlyDependencyWarning for the compose-runtime compileOnly dependency on Native / JS / Wasm targets. The dep is intentionally compileOnly so non-Compose consumers (Ktor servers, CLI tools, plain JVM) don't pull compose-runtime onto their runtime classpath — @Stable has BINARY retention and no runtime cost. Native / JS / Wasm consumers using :ksafe without Compose must declare compose-runtime themselves to compile against the published klib (accepted trade-off; promoting to api would force compose-runtime onto 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.storage field still works. New code should prefer level (IDE quick-fix offers the replacement).

Full Changelog: 2.0.0...2.1.0

2.0.0

Choose a tag to compare

@ioannisa ioannisa released this 12 May 21:07

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. Replaces ENCRYPTED as 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. KSafeBiometrics ships as the optional :ksafe-biometrics artifact — apps without biometrics no longer pull in androidx.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 stays PLAIN_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 shared appleMain. Intel Macs without a T2 fall back to plain Keychain.

  • SecurityChecker short-circuits on macOS — the iOS jailbreak heuristics would otherwise flag every Mac as rooted.

  • macosTest source set — 73 new tests plus the full common KSafeTest suite.

  • allowDeviceCredentialFallback on verifyBiometric / verifyBiometricDirect (#29, thanks @Trucodisparo). New optional Boolean (default true). Set false to 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.cryptography 0.6.0 (#27, #30, via #28, thanks @HarukeyUA @chirag38-unity). Resolves the runtime IrLinkageError on 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. KSafeCore now waits for the first snapshotFlow emission before migrating; orphan cleanup refuses to delete when DataStore is empty but Keychain has entries. Pinned by KSafeCoreStartupOrderingTest.
  • macOS biometrics now work on every Mac. Switched to LAPolicyDeviceOwnerAuthentication on macOS — falls back to login password or Apple Watch on Macs without Touch ID (Mac mini, many Intel Macs). iOS unchanged.
  • verifyBiometric (suspend) now dispatches evaluatePolicy on Main on Apple platforms, matching verifyBiometricDirect.

Changed

  • Default memory policy is now LAZY_PLAIN_TEXT (was ENCRYPTED) on Android, iOS, macOS, JVM. Apps that need ciphertext-at-rest semantics must opt in explicitly with KSafeMemoryPolicy.ENCRYPTED or ENCRYPTED_WITH_TIMED_CACHE. Web's forced PLAIN_TEXT is unchanged.
  • PLAIN_TEXT is 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_TEXT matches its steady-state read performance with a much cheaper start. PLAIN_TEXT is still supported.
  • IosKeychainEncryptionAppleKeychainEncryption (and surrounding Ios*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.DEFAULT encrypted write through one of two AES-256 master keys per datastore (a relaxed-accessibility variant and a requireUnlockedDevice = true variant), 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_ISOLATED writes still get a per-entry key (StrongBox / Secure Enclave isolation is the point). Existing v1 and 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. ENCRYPTED memory policy no longer pays a write-time penalty over PLAIN_TEXT.
  • Parallel cold-start decryptupdateCache and cleanupOrphanedCiphertext now decrypt concurrently. Cold-start time on a 1500-key encrypted store drops from ~27 ms to under 1 ms.
  • detectProtection short-circuit trusts 2.0 metadata authoritatively when present, saving an allocation and a map lookup per unencrypted read.
  • AndroidKeystoreEncryption micro-optimisations — lazy companion-level KeyStore, zero-copy GCM decrypt, single-allocation encrypt buffer, collapsed containsAlias + getKey/deleteEntry IPC round-trips.
  • AppleKeychainEncryption key-byte cache — repeat encrypt/decrypt on the same key short-circuits both SecItemCopyMatching and the SE SecKeyCreateDecryptedData ECIES unwrap. Brings Apple in line with the per-alias caches Android and JVM already had.
  • Suspend put / delete now go through the write coalescer. 500 concurrent suspend writes show 5–27× lower per-op latency depending on encryption mode.
  • hasAnyEncryptedKey atomic flag lets plain-only stores skip the protectionMap lookup 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.19.2.1, Gradle 8.14.49.4.1. :ksafe migrated from com.android.library to com.android.kotlin.multiplatform.library, aligning all three modules. androidInstrumentedTest source set renamed to androidDeviceTest. Removed obsolete gradle.properties flags 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

2.0.0-RC2 Pre-release
Pre-release

Choose a tag to compare

@ioannisa ioannisa released this 28 Apr 13:45

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
    Without close(), abandoned instances pin their DataStore scope on Dispatchers.IO. Idempotent; after close() 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

2.0.0-RC1 Pre-release
Pre-release

Choose a tag to compare

@ioannisa ioannisa released this 26 Apr 19:13

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. Float and Double share a single runtime representation with Int on JS (0f is Int returns true), so float / double reads routed into the Int branch and returned the default.
  • Nullable-typed reads with a null default lost stored primitives. For get<Int?>("counter", null), no is X branch matched (because null doesn't satisfy is Int), and the path returned null regardless 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 KSafePlatformStorage interface; shared DataStoreStorage adapter for Android / iOS / JVM lives in a new datastoreMain intermediate source set; web keeps its own LocalStorageStorage adapter for localStorage.
  • KSafeEncryption gained encryptSuspend / decryptSuspend / deleteKeySuspend; WebSoftwareEncryption overrides them with real WebCrypto calls (the blocking variants throw — WebCrypto is async-only).
  • The bulk of the previous wasmJsMain implementation moved into a shared webMain source set used by both wasmJs and js(IR). The full KSafeTest suite now runs on both targets via a shared webTest/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 WebInteropSmokeTest runs on both wasmJs and js and asserts the per-target actuals: localStorage round-trip, enumeration, currentTimeMillisWeb() plausibility, secureRandomBytes() non-determinism.
  • Internal types (KSafeCore, KSafePlatformStorage, KSafeEncryption, KeySafeMetadataManager, SecurityChecker, KSafeSecureRandom, the per-platform engines, all expect/actual shims) moved to eu.anifantakis.lib.ksafe.internal. Public-facing types stay at the root package — no consumer imports break.

Upgrade notes

  • On-disk format is unchanged. E...
Read more

1.8.1

Choose a tag to compare

@ioannisa ioannisa released this 17 Apr 01:15

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-unlock

Note: 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

Choose a tag to compare

@ioannisa ioannisa released this 13 Apr 23:07
55c4ba8

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 scope parameter 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

Choose a tag to compare

@ioannisa ioannisa released this 17 Mar 14:47

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 } via KSafeDefaults.json — no changes needed for existing code
  • kotlinx-serialization-json is declared in the library as a transitive dependency (api scope) — no need to add it manually in your project
  • Note: Changing the Json configuration for an existing fileName namespace 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 mutableStateOf Delegates 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 fileName Validation

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