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 constructions could each open a store) and only the creating instance cancelled its scope onclose(). The factory now shares a single ref-counted per-file backend behind a per-path lock: atomic creation, teardown only on the last close.
Apple (iOS / macOS)
-
Concurrent first-time key creation could clobber the key and silently lose data.
AppleKeychainEncryptionhad no lock serializing key creation, so the construction-time prewarm could race the firstDEFAULTwrites and two threads could each generate a key, the second overwriting the first after it had produced ciphertext. Key creation is now serialized (reentrant lock + double-checked cache). Upgrade recommended if you target iOS/macOS with default encryption. -
macOS: the Keychain orphan sweep could delete another KSafe-using app's keys every launch. On macOS, items live in the shared per-user login keychain with no app identity, so one app's startup sweep could classify another app's items as orphans and delete their keys. The sweep is now disabled on macOS (still active on the sandboxed Apple platforms); stale entries are reclaimed by
clearAll(). -
iOS: the startup Keychain orphan sweep no longer deletes a key for a write made concurrently with it. The sweep built its live-key set from a single entry-time snapshot, so a key created for an in-flight launch write could be reaped. It now consults the same in-flight ("dirty") set and skips any key with a write in progress.
-
"iOS app on Mac" no longer false-positives as a jailbroken device. iPhone/iPad apps running on Apple Silicon Macs execute against the real macOS filesystem, where jailbreak path probes trivially match — so the
Strictpreset threwSecurityViolationExceptionand bricked secure storage. The rooted-device check now short-circuits whenNSProcessInfo.iOSAppOnMacreports the iOS-on-Mac environment. -
A transient Secure-Enclave unwrap failure on a non-English device could destroy
HARDWARE_ISOLATEDdata. Transient-vs-permanent was decided by matching English substrings against the CFError's localized description — so on a localized device a transient failure was misclassified as corruption and triggered destructive key regeneration. Classification is now keyed on the locale-independent OSStatus code, and the decrypt path is covered too (reads retry, flows skip). -
Transient Apple Keychain read errors are now retried/skipped instead of silently returning the default. The "is this decrypt transient?" check matched Android's
Keystorewording but not Apple'sKeychain. It now recognizesKeychaintoo (KSafe's own definitive "no key" / "vault unavailable" results stay non-transient). -
Two
KSafeinstances on the same file — or a quickclose()-then-recreate — silently broke reads and writes. The Apple factory created a freshDataStoreper construction with no per-file dedup. It now uses the same ref-counted, per-file backend as Android and JVM: one sharedDataStore/ Keychain engine per path, bounded await on teardown, teardown only on the last close.
JVM / Desktop
-
(Critical) A transient OS key-vault outage at construction could permanently destroy keys and data. A transient self-test failure (locked Keychain, login keyring not yet on D-Bus) silently fell back to the software vault with the degrade flag unset, opening two data-loss paths: the orphan sweep deleted ciphertext whose key lived only in the unreachable vault, and the prewarm minted a junk key that the next healthy launch copied over the real one. A constructible-but-unreachable vault is now flagged distinctly (
"unavailable"≠"absent"), key creation fails closed (with the-Dksafe.jvm.keyVault=softwareopt-out), and the self-test canary uses a unique alias per attempt so concurrent self-tests can't fail a healthy vault. -
The auto-derived key namespace changed with the launcher, deleting encrypted data on a versioned-jar upgrade. With
appNamespaceunset, the namespace was derived fromsun.java.command(jar filename / main-class) — somyapp-1.2.3.jar→myapp-1.2.4.jarmoved the keys while the un-namespaced data stayed put, and the orphan sweep deleted everything. The default namespace is now a stable constant; an upgrade probes the old derived location and migrates the key over (write → verify → delete). -
Two
KSafeinstances on the same file — or a quickclose()-then-recreate — silently broke reads and writes. The JVM factory created a freshDataStoreper construction with no per-file dedup. It now uses the same ref-counted, per-file backend as Android (covering both the DataStore backend and the no-sun.misc.UnsafeJSON fallback). -
A runtime OS key-vault failure (Windows/macOS) now degrades safely instead of leaking a raw error. Windows DPAPI unprotect failures and a locked macOS login Keychain previously threw raw platform exceptions; they now map to KSafe's "key vault unavailable" contract (reads return defaults, the sweep leaves ciphertext intact, writes fail closed), matching Linux.
-
A stale-fallback re-migration could silently roll back newer writes on every launch. A permanently-unmigratable fallback entry blocked archival, so the construction-time gate re-ran the blocking migration every launch, re-draining frozen values over newer writes. The migration now distinguishes permanent failures (skipped, source archived) from transient ones (whole pass aborted for a clean retry), and the retry is overwrite-safe via a
.migration-pendingsnapshot. -
A transient read error could wipe the entire JSON-fallback store on the next write. The serializer caught any
IOExceptionand returned an empty store, which DataStore cached and the next write persisted over the real file. Mid-read errors now propagate (DataStore won't write on a failed read); genuine corruption still routes to the quarantine handler. -
The software key file is now fsync'd before the atomic rename (no-
sun.misc.Unsafefallback). A power loss right after the rename could leave a zero-length key file, which reads as "no keys" and triggers the orphan sweep to delete every entry. The temp file is now forced to disk before the move. -
clearAll()now wipes leftover fallback files that held recoverable secrets. Migration left*.ksafe.json.migrated(ciphertext) and*.ksafe-keys.json.migrated(plaintext AES keys) archived;clearAll()deleted only the live store, leaving every pre-migration secret decryptable offline. It now also deletes these.migrated/.corrupt-*residuals (matched by the safe's own prefix). -
Data written during a second JSON-fallback period now migrates forward. The migration gate keyed off the permanent
.migratedarchive, so togglingjdk.unsupportedoff and on again stranded the fresh fallback data. The gate now migrates whenever the live fallback file is newer than the last migration.
Web (Kotlin/JS + Wasm)
-
Nested store names shared and destroyed each other's data, and
"default"collided with the unnamed instance. Theksafe_<fileName>_prefix wasn't prefix-free, soKSafe("user").clearAll()permanently deletedKSafe("user_cache"), andKSafe(fileName = "default")clobbered the unnamedKSafe()with mutually undecryptable ciphertext. The namespace is now prefix-free (ksafe.<name>:), with a one-time copy → verify → delete migration that carries existing data forward. -
Two browser tabs racing on first launch could mint divergent keys, losing one tab's encrypted writes. The IndexedDB get-or-create was check-then-act with no atomic guard, so both tabs could generate a key — the loser kept its non-extractable key in page memory and encrypted with it, then lost those values on reload. Key creation now uses an atomic IndexedDB
add, so all tabs converge on one key. -
A failed
localStoragewrite could permanently lose an unrelated key's value during rollback. The rollback restored in arbitrary order and swallowed its own failures, so near the quota a restore could fail silently while the batch reported atomic failure. Rollback now removes every touched key first (freeing the space), then restores, and surfaces any restore that still fails. -
A key deleted in one tab is now invalidated in the others. Tabs cached the
CryptoKeyin page-local memory and kept encrypting under a key another tab had deleted. Tabs now coordinate over aBroadcastChannelto evict the stale handle (unchanged whereBroadcastChannelis unavailable).
ksafe-biometrics
-
Android: two concurrent biometric prompts hung the first caller forever. The androidx
BiometricPromptview-model overwrites its callback on each construction, so two overlapping calls resumed only the second caller and left the first suspended forever. Prompt presentation is now serialized through a process-wide gate; a cancelled caller releases it. -
Android:
verifyBiometricDirectnow delivers its result on the main thread. The callback fired onDispatchers.Default, crashing View-based consumers that touched the UI.onResultis now posted to the main looper, matching Apple. -
Android + Apple: a no-cache (
duration <= 0) authorization no longer primes the session cache. The cache write recorded any non-null duration — including the0/negative value passed to opt out — so a later wider window could reuse it. The write now also requiresduration > 0, matching the read. -
Android + Apple: a
null(global) scope and an empty-string scope no longer share one session. Both mapped a null scope to"", soscope = ""shared the global slot. Scopes are now namespaced sonulland""(and every caller string) occupy distinct slots. -
Android: a cached authorization could outlive its window across device sleep. The monotonic TTL source (
System.nanoTime()/CLOCK_MONOTONIC) freezes during deep sleep, so a 60-second window became "60 seconds of awake time". The TTL now usesSystemClock.elapsedRealtime()(CLOCK_BOOTTIME) — monotonic and counts sleep. Apple is unaffected.
ksafe-compose
-
The cold-start self-heal could clobber a concurrent user write off-thread. For
mutableStateOf(...)without an explicitscope, the "user has already written" flag wasn't@Volatile, so the background self-heal could miss the setter's update and overwrite the user's value with the default. The flag is now@Volatile. -
Live external-change observation could clobber the user's own in-flight edits. A
scope/observeExternalChanges = truestate applied everygetFlowemission, so a stale disk snapshot could revert the visible state mid-gesture (dropping typed characters in aTextField). Observation now suppresses emissions precisely while the user's own write propagates, resuming once the flow catches up. -
rememberKSafeStatekept reading/writing the originalKSafeinstance after the instance or write mode was swapped. It memoized on the storagekeyalone, so swapping instances (multi-account apps) recomposed with the new instance but returned the old state — leaking reads/writes into the previous account's store. The state is now keyed on every baked-in parameter (instance identity,mode,policy,defaultValue, and the storage key).
Tests
Extensive regression coverage was added across every target — each test pins the exact failure it fixes (most are red without the fix). Highlights:
AndroidSoftwareDekTest(10 cases, verified on a Galaxy S24 Ultra) — DEK round-trip, header routing, wrapped-DEK persistence, cross-version read, strict/HARDWARE_ISOLATEDstaying on the TEE path, self-healing corrupt DEK, lazy creation, and a concurrency stress confirming exactly one DEK per burst.AndroidDataStoreLifecycleTest/AndroidMultiInstanceTest(S24 Ultra) — close→recreate loops and ref-counted multi-instance read/write.JvmNumericTypeMigrationTest(27 cases) — the full numeric cross-type matrix (plain + encrypted), including out-of-range / fractional fallbacks.JvmKeyVaultMigrationTest(+13 cases) — construction-time vault-unavailable handling, concurrent self-tests, the 2.1.x → 2.1.2 namespace-upgrade recovery, and runtime "unavailable" classification.JvmBatchFailureIsolationTest,JvmGetOrCreateSecretTest,JvmMultiInstanceTest,JvmClearAllRaceRepairTest,JvmSideCacheWriteBackRaceTest,JvmCollectorWriteRaceTest,JvmObservableFlowResilienceTest,JvmFallbackMigrationTest,JvmJsonSerializerReadFailureTest,JvmClearAllResidualFilesTest,JvmCorruptStoreRecoveryTest,JvmDeleteKeyCleanupFailureTest,JvmAppNamespaceTest— the JVM/core data-loss and concurrency fixes.IosKeychainConcurrencyTest,MacosKeychainSweepTest,MacosSecUnwrapClassificationTest,MacosMultiInstanceTest— Apple key-creation serialization, the macOS sweep no-op, OSStatus-based transient classification, and multi-instance.WebPrefixIsolationTest,LocalStorageRollbackTest,WebKeyStoreIntegrationTest— web prefix isolation, quota rollback, and real IndexedDB / WebCrypto key creation in Chrome (jsBrowserTest+wasmJsBrowserTest).BiometricPromptGateTest,BiometricAuthSessionTest— the biometrics prompt gate and session-cache decisions.ObserveFromStorageTest,KSafeStateFlowClobberTest— Compose / StateFlow stale-echo suppression.KSafeTest(+3 cases on every target) —Durationand custom-serializer round-trips in Plain and Encrypted modes.
Documentation
- Benchmarks refreshed to real-device numbers.
docs/BENCHMARKS.md,README.md,docs/COMPARISON.md, anddocs/USAGE.mdnow report a Samsung Galaxy S24 Ultra (release build, 500 iterations) instead of an emulator, with the 2.1.2 DEK fast path reflected — including the corrected "userspace decrypt on Android" story and the reversal against EncryptedSharedPreferences / KVault on encrypted reads.docs/ARCHITECTURE.md's pure-CPU-decrypt note now correctly attributes Android's userspace decrypt to 2.1.2.
Full details: see CHANGELOG.md.
Full Changelog: 2.1.1...2.1.2