Skip to content

Commit ea71db3

Browse files
committed
feat: release 1.6.0 - WASM/JS Target, StateFlow API, Encryption Fixes
- Add WASM/JS as fourth platform target with WebCrypto AES-256-GCM encryption and localStorage persistence. - New reactive StateFlow API (getFlow/getStateFlow) for all platforms. - Fix key generation race conditions on JVM, deleteKey cache coherence on Android/JVM, and DataStore multi-instance crash on Android DI re-initialization. - Remove IntegrityChecker (unrelated Play Integrity/DeviceCheck wrapper).
1 parent d36f304 commit ea71db3

File tree

20 files changed

+1841
-748
lines changed

20 files changed

+1841
-748
lines changed

CHANGELOG.md

Lines changed: 93 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,99 @@
22

33
All notable changes to KSafe will be documented in this file.
44

5+
## [1.6.0] - 2025-02-16
6+
7+
### Added
8+
9+
#### WASM/JS Target
10+
11+
KSafe now runs in the browser. New platform source sets (`wasmJsMain`, `wasmJsTest`) and a `ksafe-compose` WASM target bring encrypted key-value storage to Kotlin/WASM.
12+
13+
- **Storage:** Browser `localStorage` via `@JsFun` externals (in `LocalStorage.kt`)
14+
- **Encryption:** WebCrypto AES-256-GCM via `cryptography-provider-webcrypto`
15+
- **Memory policy:** Always `PLAIN_TEXT` internally — WebCrypto is async-only, so all values are decrypted at init and held as plaintext in a `HashMap`
16+
- **Key namespace:** `ksafe_{fileName}_{key}` for data, `ksafe_key_{alias}` for encryption keys
17+
- **Mutex-protected key generation** to prevent race conditions in single-threaded coroutine environments
18+
- **Per-operation error isolation** in batch writes — a single failed operation doesn't discard the entire batch
19+
- **`yield()`-based StateFlow propagation** — required because WASM is single-threaded and has no implicit suspension points like JVM's DataStore I/O
20+
- **No** `runBlocking`, `ConcurrentHashMap`, `Dispatchers.IO`, or `AtomicBoolean` — all replaced with WASM-compatible equivalents
21+
- New WASM-specific `actual` implementations: `KSafe.wasmJs.kt`, `WasmSoftwareEncryption.kt`, `LocalStorage.kt`, `SecurityChecker.wasmJs.kt`
22+
23+
#### StateFlow API
24+
25+
New reactive API for observing KSafe values as flows, available on all four platforms:
26+
27+
- `getFlow(key, defaultValue, encrypted)` — returns a cold `Flow<T>` that emits whenever the underlying value changes
28+
- `getStateFlow(key, defaultValue, encrypted, scope)` — convenience extension that converts the cold flow into a hot `StateFlow<T>` using `stateIn(scope, SharingStarted.Eagerly, defaultValue)`
29+
- Works with both encrypted and unencrypted values
30+
- On DataStore platforms (Android, JVM, iOS), backed by `DataStore.data.map {}` with `distinctUntilChanged()`
31+
- On WASM, backed by a `MutableStateFlow` that is updated on writes
32+
33+
#### ksafe-compose WASM Target
34+
35+
The `ksafe-compose` module now includes a `wasmJs` target, enabling `mutableStateOf` persistence in Compose for Web.
36+
37+
### Fixed
38+
39+
#### Android: DataStore "multiple active instances" crash on DI re-initialization
40+
41+
When using Koin Compose Multiplatform (`KoinMultiplatformApplication {}`), the Koin application context can be recreated on Activity restart (configuration changes such as rotation, locale change, or dark mode toggle). This causes all `single {}` definitions to be re-instantiated, including KSafe. Each new KSafe instance created a new DataStore for the same file, triggering:
42+
43+
```
44+
IllegalStateException: There are multiple DataStores active for the same file
45+
```
46+
47+
**Root cause:** Each `KSafe` constructor eagerly created its own `DataStore` instance. DataStore enforces a single-instance-per-file invariant. When Koin re-created KSafe with the same `fileName`, a second DataStore was created for the same file.
48+
49+
**Fix:** Added a process-level `ConcurrentHashMap<String, DataStore<Preferences>>` cache in the Android `companion object`. If a DataStore already exists for a given file name, it is reused instead of creating a new one. This fix is Android-only because configuration changes (Activity recreation) are an Android-specific lifecycle concept — iOS, JVM, and WASM do not re-initialize their DI containers during normal operation.
50+
51+
#### Key generation race condition (JVM)
52+
53+
Concurrent `putEncrypted` calls for the same key alias could trigger parallel key generation in `JvmSoftwareEncryption`, producing different AES keys. One key would be stored in DataStore while a different one was used to encrypt data, causing permanent data loss on the next read.
54+
55+
**Fix:** Added a `ConcurrentHashMap<String, SecretKey>` in-memory key cache and per-alias `synchronized` locks to `JvmSoftwareEncryption.getOrCreateSecretKey()`. The first caller generates and caches the key; subsequent callers return the cached key immediately.
56+
57+
#### deleteKey race with key cache repopulation (Android + JVM)
58+
59+
`deleteKey()` removed the key from the Keystore/DataStore but not from the in-memory cache (Android) or had no cache at all (JVM). A concurrent `encrypt()` call could re-cache the stale key before the delete completed, causing subsequent encryptions to use a key that no longer existed in persistent storage.
60+
61+
**Fix:** `deleteKey()` now holds the same per-alias lock as `getOrCreateKey()` and removes the key from both the persistent store and the in-memory cache atomically. Applied to both `AndroidKeystoreEncryption` and `JvmSoftwareEncryption`.
62+
63+
#### Replaced `intern()` lock strategy with dedicated lock map (Android + JVM)
64+
65+
Both `AndroidKeystoreEncryption` and `JvmSoftwareEncryption` used `synchronized(identifier.intern())` for per-alias locking. `String.intern()` adds strings to the JVM's permanent string pool, which is never garbage collected. With dynamic key aliases (e.g., per-user keys), this caused unbounded memory growth.
66+
67+
**Fix:** Replaced with `ConcurrentHashMap<String, Any>` lock maps and a `lockFor(alias)` helper. Lock objects are scoped to the encryption engine instance and eligible for GC when the engine is collected.
68+
69+
### Removed
70+
71+
- **`IntegrityChecker`** — Removed from all platforms (Android, iOS, JVM, WASM). This was a wrapper around Google Play Integrity (Android) and Apple DeviceCheck (iOS) that generated tokens for server-side device verification. It had no connection to KSafe's core encrypted storage functionality and added transitive dependencies (`play-integrity`, `play-services-base`) to every consumer. Client-side root/jailbreak detection remains available via `SecurityChecker` and `KSafeSecurityPolicy`.
72+
- Removed `play-integrity` and `play-services-base` dependencies from Android
73+
74+
### Changed
75+
76+
- `datastore-preferences-core` dependency moved from `commonMain` to per-platform source sets (Android, JVM, iOS) — WASM uses `localStorage` and does not depend on DataStore
77+
78+
### Added (Testing)
79+
80+
- `Jvm160FixesTest` — 9 new JVM tests covering the three encryption engine fixes:
81+
- `testConcurrentEncryptedWritesSameKey_noDataLoss` — verifies concurrent encrypted writes to the same key all produce readable values
82+
- `testConcurrentEncryptedWritesDifferentKeys_allReadable` — verifies concurrent writes to different keys don't interfere
83+
- `testKeyGenerationRaceStress` — stress test with 20 threads writing to the same key
84+
- `testDeleteKeyDoesNotLeaveStaleCache` — verifies deleted keys return default on re-read
85+
- `testDeleteKeyRaceWithConcurrentEncryption` — verifies delete + encrypt race doesn't corrupt data
86+
- `testRepeatedDeleteAndRewriteCycles` — 50 cycles of delete + rewrite with integrity checks
87+
- `testManyUniqueAliasesWork` — 100 unique aliases to verify lock map scalability
88+
- `testLockMapSerializesPerAlias` — verifies per-alias serialization with concurrent readers/writers
89+
- `testDynamicStringAliasesShareLock` — verifies dynamically constructed alias strings share the same lock
90+
- `WasmJsKSafeTest` — WASM test suite extending `KSafeTest` with `FakeEncryption` (WebCrypto requires browser)
91+
92+
### Dependencies
93+
94+
- Added `cryptography-provider-webcrypto` for WASM target
95+
96+
---
97+
598
## [1.5.0] - 2025-02-09
699

7100
### Added
@@ -428,29 +521,6 @@ Background: collect 16ms window → encrypt all → single DataStore.edit()
428521
- **Emulator detection** - Detect emulators/simulators (Android & iOS)
429522
- **Debug build detection** - Detect debug builds
430523

431-
#### Platform Integrity Verification
432-
- **New `IntegrityChecker`** class for server-side device verification
433-
- **Google Play Integrity** (Android) - Generates tokens for server verification
434-
- Requires Google Cloud project number
435-
- Graceful fallback on non-GMS devices (Huawei, Amazon Fire)
436-
- **Apple DeviceCheck** (iOS) - Generates tokens for server verification
437-
- No additional configuration needed
438-
- **JVM** - Returns `IntegrityResult.NotSupported`
439-
```kotlin
440-
// Android
441-
val checker = IntegrityChecker(context, cloudProjectNumber = 123456789L)
442-
443-
// iOS
444-
val checker = IntegrityChecker()
445-
446-
when (val result = checker.requestIntegrityToken(nonce)) {
447-
is IntegrityResult.Success -> sendToServer(result.token)
448-
is IntegrityResult.Error -> handleError(result.message)
449-
is IntegrityResult.NotSupported -> fallback()
450-
}
451-
```
452-
- ⚠️ **Important**: Tokens MUST be verified server-side. Client-side verification is insecure.
453-
454524
#### Compose Support
455525
- **New `UiSecurityViolation`** - Immutable wrapper for `SecurityViolation` ensuring Compose stability
456526
```kotlin
@@ -463,7 +533,6 @@ Background: collect 16ms window → encrypt all → single DataStore.edit()
463533
### Added (Testing)
464534
- **Comprehensive test suite** for new security features:
465535
- `KSafeSecurityPolicyTest` - SecurityAction, SecurityViolation, presets
466-
- `IntegrityCheckerTest` - IntegrityResult sealed class behavior
467536
- `BiometricAuthorizationDurationTest` - Duration and scope patterns
468537
- `KSafeMemoryPolicyTest` - Memory policy enum
469538
- `JvmSecurityCheckerTest` - JVM-specific security behavior

0 commit comments

Comments
 (0)