Skip to content

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 constructions could each open a store) and only the creating instance cancelled its scope on close(). 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. AppleKeychainEncryption had no lock serializing key creation, so the construction-time prewarm could race the first DEFAULT writes 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 Strict preset threw SecurityViolationException and bricked secure storage. The rooted-device check now short-circuits when NSProcessInfo.iOSAppOnMac reports the iOS-on-Mac environment.

  • A transient Secure-Enclave unwrap failure on a non-English device could destroy HARDWARE_ISOLATED data. 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 Keystore wording but not Apple's Keychain. It now recognizes Keychain too (KSafe's own definitive "no key" / "vault unavailable" results stay non-transient).

  • Two KSafe instances on the same file — or a quick close()-then-recreate — silently broke reads and writes. The Apple factory created a fresh DataStore per construction with no per-file dedup. It now uses the same ref-counted, per-file backend as Android and JVM: one shared DataStore / 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=software opt-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 appNamespace unset, the namespace was derived from sun.java.command (jar filename / main-class) — so myapp-1.2.3.jarmyapp-1.2.4.jar moved 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 KSafe instances on the same file — or a quick close()-then-recreate — silently broke reads and writes. The JVM factory created a fresh DataStore per 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.Unsafe JSON 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-pending snapshot.

  • A transient read error could wipe the entire JSON-fallback store on the next write. The serializer caught any IOException and 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.Unsafe fallback). 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 .migrated archive, so toggling jdk.unsupported off 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. The ksafe_<fileName>_ prefix wasn't prefix-free, so KSafe("user").clearAll() permanently deleted KSafe("user_cache"), and KSafe(fileName = "default") clobbered the unnamed KSafe() 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 localStorage write 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 CryptoKey in page-local memory and kept encrypting under a key another tab had deleted. Tabs now coordinate over a BroadcastChannel to evict the stale handle (unchanged where BroadcastChannel is unavailable).

ksafe-biometrics

  • Android: two concurrent biometric prompts hung the first caller forever. The androidx BiometricPrompt view-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: verifyBiometricDirect now delivers its result on the main thread. The callback fired on Dispatchers.Default, crashing View-based consumers that touched the UI. onResult is 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 the 0/negative value passed to opt out — so a later wider window could reuse it. The write now also requires duration > 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 "", so scope = "" shared the global slot. Scopes are now namespaced so null and "" (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 uses SystemClock.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 explicit scope, 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 = true state applied every getFlow emission, so a stale disk snapshot could revert the visible state mid-gesture (dropping typed characters in a TextField). Observation now suppresses emissions precisely while the user's own write propagates, resuming once the flow catches up.

  • rememberKSafeState kept reading/writing the original KSafe instance after the instance or write mode was swapped. It memoized on the storage key alone, 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_ISOLATED staying 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) — Duration and custom-serializer round-trips in Plain and Encrypted modes.

Documentation

  • Benchmarks refreshed to real-device numbers. docs/BENCHMARKS.md, README.md, docs/COMPARISON.md, and docs/USAGE.md now 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