diff --git a/app/src/main/kotlin/org/libremail/data/security/DatabaseKeyStore.kt b/app/src/main/kotlin/org/libremail/data/security/DatabaseKeyStore.kt index 74c3356..99551b8 100644 --- a/app/src/main/kotlin/org/libremail/data/security/DatabaseKeyStore.kt +++ b/app/src/main/kotlin/org/libremail/data/security/DatabaseKeyStore.kt @@ -2,6 +2,7 @@ package org.libremail.data.security import android.content.Context +import androidx.annotation.VisibleForTesting import androidx.datastore.core.DataStore import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.booleanPreferencesKey @@ -44,6 +45,16 @@ class DatabaseKeyStore @Inject constructor( ) { private val generationLock = Mutex() + /** + * The DataStore that persists the sealed passphrases (its own `libremail_dbkey` file, never the + * Room DB it protects). Exposed as a [VisibleForTesting] seam — mirroring AppLockViewModel's + * injectable dispatcher — so the dual-seal exchange invariants are exercisable in JVM unit tests + * against an in-memory store, decoupled from the device-only Keystore that produces the sealed + * blobs. Production always uses the real per-app [dbKeyDataStore]. + */ + @VisibleForTesting + internal var dataStore: DataStore = context.dbKeyDataStore + /** * Resolve the passphrase needed to open (or convert) the on-disk cache, keyed off which seal * actually EXISTS — not off the app-lock setting, which lives in a separate DataStore and can be @@ -108,7 +119,7 @@ class DatabaseKeyStore @Inject constructor( suspend fun sealWithAuth(): Unit = generationLock.withLock { val plain = masterSealed() ?: session.current() ?: generateHex() val sealed = authCipher.encrypt(plain) - context.dbKeyDataStore.edit { + dataStore.edit { it[SEALED_AUTH] = sealed it.remove(SEALED_MASTER) } @@ -123,7 +134,7 @@ class DatabaseKeyStore @Inject constructor( */ suspend fun sealWithMaster(): Unit = generationLock.withLock { val plain = session.current() ?: read(SEALED_AUTH)?.let { authCipher.decrypt(it) } ?: return@withLock - context.dbKeyDataStore.edit { + dataStore.edit { it[SEALED_MASTER] = crypto.encrypt(plain) it.remove(SEALED_AUTH) } @@ -140,7 +151,7 @@ class DatabaseKeyStore @Inject constructor( * encrypted database file in the same operation, otherwise it becomes permanently unreadable. */ suspend fun resetSealedPassphrase(): Unit = generationLock.withLock { - context.dbKeyDataStore.edit { + dataStore.edit { it.remove(SEALED_AUTH) it.remove(SEALED_MASTER) } @@ -155,7 +166,7 @@ class DatabaseKeyStore @Inject constructor( * the corruption-safe way to "clear + re-sync" after a screen-lock change invalidates the key. */ suspend fun setClearPending() { - context.dbKeyDataStore.edit { it[CLEAR_PENDING] = true } + dataStore.edit { it[CLEAR_PENDING] = true } } /** @@ -163,20 +174,20 @@ class DatabaseKeyStore @Inject constructor( * perform the wipe (+ [resetSealedPassphrase]) FIRST and then call [clearClearPending], so a crash * mid-wipe simply repeats the idempotent wipe next start instead of stranding an unreadable file. */ - suspend fun isClearPending(): Boolean = context.dbKeyDataStore.data.first()[CLEAR_PENDING] == true + suspend fun isClearPending(): Boolean = dataStore.data.first()[CLEAR_PENDING] == true /** Clear the wipe flag. Call ONLY after the wipe + [resetSealedPassphrase] have completed. */ suspend fun clearClearPending() { - context.dbKeyDataStore.edit { it.remove(CLEAR_PENDING) } + dataStore.edit { it.remove(CLEAR_PENDING) } } private suspend fun masterSealed(): String? = read(SEALED_MASTER)?.let { crypto.decrypt(it) } - private suspend fun read(key: Preferences.Key): String? = context.dbKeyDataStore.data.first()[key] + private suspend fun read(key: Preferences.Key): String? = dataStore.data.first()[key] private suspend fun generateAndSealMaster(): String { val hex = generateHex() - context.dbKeyDataStore.edit { it[SEALED_MASTER] = crypto.encrypt(hex) } + dataStore.edit { it[SEALED_MASTER] = crypto.encrypt(hex) } return hex } diff --git a/app/src/test/kotlin/org/libremail/data/security/DatabaseKeyStoreTest.kt b/app/src/test/kotlin/org/libremail/data/security/DatabaseKeyStoreTest.kt new file mode 100644 index 0000000..6370ff6 --- /dev/null +++ b/app/src/test/kotlin/org/libremail/data/security/DatabaseKeyStoreTest.kt @@ -0,0 +1,256 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.data.security + +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.booleanPreferencesKey +import androidx.datastore.preferences.core.emptyPreferences +import androidx.datastore.preferences.core.stringPreferencesKey +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.runTest +import org.junit.Before +import org.junit.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * Pins the [DatabaseKeyStore] dual-seal exchange (issue #100). The device-only Keystore that produces + * the sealed blobs is mocked with a reversible cipher, and the persistence runs against an in-memory + * [DataStore] injected through the [DatabaseKeyStore.dataStore] seam, so the security-critical + * invariants — "never both seals at once" and "an auth-sealed passphrase is not recoverable without + * authentication" — are exercised deterministically on the JVM instead of only on a device. + * + * [crypto] models the non-auth master seal as `m:`; [authCipher] models the auth-bound seal as + * `a:`. Both are reversible so a resealed passphrase round-trips, which is exactly what keeps an + * already-encrypted cache readable across an app-lock toggle. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class DatabaseKeyStoreTest { + + private val store = InMemoryPreferencesDataStore() + private val crypto = mockk(relaxed = true) + private val authCipher = mockk(relaxed = true) + private val session = PassphraseSession() + + @Before + fun setUp() { + every { crypto.encrypt(any()) } answers { "m:" + firstArg() } + every { crypto.decrypt(any()) } answers { firstArg().removePrefix("m:") } + every { authCipher.encrypt(any()) } answers { "a:" + firstArg() } + every { authCipher.decrypt(any()) } answers { firstArg().removePrefix("a:") } + } + + private fun keyStore(): DatabaseKeyStore = + DatabaseKeyStore(mockk(relaxed = true), crypto, authCipher, session).also { it.dataStore = store } + + @Test + fun `passphrase mints a master-sealed key on first use and never alongside an auth seal`() = runTest { + val keyStore = keyStore() + assertEquals(SealState.NONE, keyStore.sealState()) + + val passphrase = keyStore.passphrase() + + assertEquals(SealState.MASTER, keyStore.sealState()) + assertEquals(HEX_LEN, passphrase.length, "the SQLCipher passphrase is 32 bytes rendered as hex") + // Exactly one seal exists: the master copy is present and no auth copy was written. + assertNotNull(store.data.first()[SEALED_MASTER]) + assertNull(store.data.first()[SEALED_AUTH]) + // Idempotent: a second call returns the SAME passphrase rather than regenerating one (a second + // key would strand the DB under a passphrase we could no longer reproduce). + assertEquals(passphrase, keyStore.passphrase()) + } + + @Test + fun `sealWithAuth replaces the master seal with an auth seal and unlocks the session`() = runTest { + val keyStore = keyStore() + val passphrase = keyStore.passphrase() // start master-sealed (app-lock off) + + keyStore.sealWithAuth() + + // Never both seals at once: enabling app-lock drops the master copy so the cache key is no + // longer recoverable without authentication. + assertEquals(SealState.AUTH, keyStore.sealState()) + assertNull(store.data.first()[SEALED_MASTER]) + assertEquals("a:$passphrase", store.data.first()[SEALED_AUTH]) + // The SAME passphrase is resealed (an already-encrypted cache stays readable) and unlocked into + // the session so the DB opens this session. + assertTrue(keyStore.hasAuthSealedPassphrase()) + assertEquals(passphrase, session.current()) + } + + @Test + fun `sealWithAuth mints a fresh passphrase when neither a seal nor a session value exists`() = runTest { + val keyStore = keyStore() + assertEquals(SealState.NONE, keyStore.sealState()) + + keyStore.sealWithAuth() + + assertEquals(SealState.AUTH, keyStore.sealState()) + assertNull(store.data.first()[SEALED_MASTER]) + val minted = session.current() + assertNotNull(minted) + assertEquals(HEX_LEN, minted.length) + assertEquals("a:$minted", store.data.first()[SEALED_AUTH]) + } + + @Test + fun `unlockWithAuth unwraps the auth-sealed passphrase into the session`() = runTest { + val keyStore = keyStore() + keyStore.sealWithAuth() + val passphrase = requireNotNull(session.current()) + session.lock() // simulate a fresh session that must unwrap after the user authenticates + + keyStore.unlockWithAuth() + + assertEquals(passphrase, session.current()) + } + + @Test + fun `unlockWithAuth is a no-op when nothing is auth-sealed`() = runTest { + val keyStore = keyStore() + + keyStore.unlockWithAuth() + + assertNull(session.current()) + verify(exactly = 0) { authCipher.decrypt(any()) } + } + + @Test + fun `sealWithMaster replaces the auth seal with a master seal, deletes the auth key, and locks`() = runTest { + val keyStore = keyStore() + keyStore.sealWithAuth() + val passphrase = requireNotNull(session.current()) + + keyStore.sealWithMaster() + + // Never both seals at once: disabling app-lock drops the auth copy and reseals under the master. + assertEquals(SealState.MASTER, keyStore.sealState()) + assertNull(store.data.first()[SEALED_AUTH]) + assertEquals("m:$passphrase", store.data.first()[SEALED_MASTER]) + // The now-orphaned auth-bound key is deleted so a later invalidation can't trigger a spurious + // wipe, and the session is dropped so the cache opens without auth again. + verify { authCipher.deleteKey() } + assertNull(session.current()) + // Master-sealed value is recoverable WITHOUT authentication (that is the whole point of disable). + assertEquals(passphrase, keyStore.passphrase()) + } + + @Test + fun `sealWithMaster decrypts the auth seal when the session is already locked`() = runTest { + val keyStore = keyStore() + keyStore.sealWithAuth() + val passphrase = requireNotNull(session.current()) + session.lock() // no session value: sealWithMaster must fall back to decrypting the auth seal + + keyStore.sealWithMaster() + + assertEquals(SealState.MASTER, keyStore.sealState()) + assertEquals("m:$passphrase", store.data.first()[SEALED_MASTER]) + assertNull(store.data.first()[SEALED_AUTH]) + } + + @Test + fun `sealWithMaster does nothing when neither a session value nor an auth seal exists`() = runTest { + val keyStore = keyStore() + + keyStore.sealWithMaster() + + assertEquals(SealState.NONE, keyStore.sealState()) + verify(exactly = 0) { authCipher.deleteKey() } + } + + @Test + fun `resetSealedPassphrase drops every seal, deletes the auth key, and locks the session`() = runTest { + val keyStore = keyStore() + keyStore.sealWithAuth() // auth-sealed + session unlocked + + keyStore.resetSealedPassphrase() + + assertEquals(SealState.NONE, keyStore.sealState()) + assertNull(store.data.first()[SEALED_AUTH]) + assertNull(store.data.first()[SEALED_MASTER]) + verify { authCipher.deleteKey() } + assertNull(session.current()) + } + + @Test + fun `passphrase refuses to mint a master key while an auth seal exists`() = runTest { + val keyStore = keyStore() + keyStore.sealWithAuth() // an auth seal now exists + session.lock() + + // Minting a master passphrase now would strand the real (auth-sealed) key and leave the DB + // encrypted under a passphrase we could never reproduce — so it fails loudly instead of quietly + // creating a second, recoverable-without-auth copy. + assertFailsWith { keyStore.passphrase() } + assertEquals(SealState.AUTH, keyStore.sealState()) + assertNull(store.data.first()[SEALED_MASTER]) + } + + @Test + fun `resolvePassphrase returns the master-sealed value without authentication when app-lock is off`() = runTest { + val keyStore = keyStore() + val passphrase = keyStore.passphrase() // master-sealed + + assertEquals(passphrase, keyStore.resolvePassphrase(appLockEnabled = false)) + } + + @Test + fun `resolvePassphrase returns the authenticated session value when an auth seal exists`() = runTest { + val keyStore = keyStore() + keyStore.sealWithAuth() + val passphrase = requireNotNull(session.current()) + + // AUTH seal: the value lives only in the session after the user authenticates — read it there, + // never re-derive or regenerate it. + assertEquals(passphrase, keyStore.resolvePassphrase(appLockEnabled = true)) + } + + @Test + fun `clear-pending flag round-trips through set, query, and clear`() = runTest { + val keyStore = keyStore() + assertFalse(keyStore.isClearPending()) + + keyStore.setClearPending() + assertTrue(keyStore.isClearPending()) + assertEquals(true, store.data.first()[CLEAR_PENDING]) + + keyStore.clearClearPending() + assertFalse(keyStore.isClearPending()) + assertNull(store.data.first()[CLEAR_PENDING]) + } + + private companion object { + // Same key names DatabaseKeyStore persists under, so the raw store can be inspected directly. + val SEALED_MASTER = stringPreferencesKey("sealed_db_key") + val SEALED_AUTH = stringPreferencesKey("sealed_db_key_auth") + val CLEAR_PENDING = booleanPreferencesKey("clear_encrypted_cache_pending") + const val HEX_LEN = 64 // 32 random bytes rendered as hex + } +} + +/** + * A minimal in-memory [DataStore] of [Preferences] backed by a [MutableStateFlow], substituted for the + * device-backed file store so the seal exchange is JVM-testable. `edit { }` routes through [updateData]. + */ +private class InMemoryPreferencesDataStore : DataStore { + private val flow = MutableStateFlow(emptyPreferences()) + override val data: Flow = flow.asStateFlow() + + override suspend fun updateData(transform: suspend (Preferences) -> Preferences): Preferences { + val updated = transform(flow.value) + flow.value = updated + return updated + } +} diff --git a/app/src/test/kotlin/org/libremail/data/security/KeyInvalidationPolicyTest.kt b/app/src/test/kotlin/org/libremail/data/security/KeyInvalidationPolicyTest.kt index 66fab93..47a9994 100644 --- a/app/src/test/kotlin/org/libremail/data/security/KeyInvalidationPolicyTest.kt +++ b/app/src/test/kotlin/org/libremail/data/security/KeyInvalidationPolicyTest.kt @@ -53,28 +53,59 @@ class KeyInvalidationPolicyTest { } @Test - fun `full decision table is pinned`() { - // App-lock off: always proceed, regardless of the other three inputs (all 8 combinations). - for (e in listOf(false, true)) { - for (s in listOf(false, true)) { - for (i in listOf(false, true)) { - assertEquals( - LockAction.PROCEED, - decide(appLock = false, encrypt = e, secure = s, invalidated = i), - ) - } - } + fun `every one of the 16 input combinations maps to its pinned action`() { + // The complete truth table for decide(appLock, encrypt, secure, invalidated): all 2^4 = 16 rows + // listed explicitly, so a mutation of ANY branch is caught — most importantly the common + // (on, *, secure, valid) rows, whose silent flip to PROCEED would be a lock bypass. The + // completeness guard below fails if a row is ever dropped, keeping the table exhaustive. + // + // Columns: appLock, encrypt, secure, invalidated -> expected action. + val table = listOf( + // App-lock OFF: always PROCEED, whatever the other three inputs are. + Case(false, false, false, false, LockAction.PROCEED), + Case(false, false, false, true, LockAction.PROCEED), + Case(false, false, true, false, LockAction.PROCEED), + Case(false, false, true, true, LockAction.PROCEED), + Case(false, true, false, false, LockAction.PROCEED), + Case(false, true, false, true, LockAction.PROCEED), + Case(false, true, true, false, LockAction.PROCEED), + Case(false, true, true, true, LockAction.PROCEED), + // App-lock ON, device NOT secure (lock removed): clear+disable iff a cache exists, else disable. + Case(true, true, false, false, LockAction.CLEAR_AND_DISABLE), + Case(true, true, false, true, LockAction.CLEAR_AND_DISABLE), + Case(true, false, false, false, LockAction.DISABLE_APP_LOCK), + Case(true, false, false, true, LockAction.DISABLE_APP_LOCK), + // App-lock ON, secure, key invalidated: clear+re-auth iff a cache exists, else just re-auth. + Case(true, true, true, true, LockAction.CLEAR_AND_REQUIRE_AUTH), + Case(true, false, true, true, LockAction.REQUIRE_AUTH), + // App-lock ON, secure, key valid: the common case — require auth, no wipe. + Case(true, true, true, false, LockAction.REQUIRE_AUTH), + Case(true, false, true, false, LockAction.REQUIRE_AUTH), + ) + + // Exhaustiveness: exactly the 16 distinct (appLock, encrypt, secure, invalidated) combinations. + assertEquals(16, table.size, "the table must list all 2^4 input combinations") + assertEquals( + 16, + table.map { listOf(it.appLock, it.encrypt, it.secure, it.invalidated) }.toSet().size, + "every row must be a distinct input combination", + ) + + for (case in table) { + assertEquals( + case.expected, + decide(case.appLock, case.encrypt, case.secure, case.invalidated), + "decide(appLock=${case.appLock}, encrypt=${case.encrypt}, " + + "secure=${case.secure}, invalidated=${case.invalidated})", + ) } - // App-lock on, device no longer secure: clear+disable iff there is an encrypted cache to lose. - assertEquals(LockAction.CLEAR_AND_DISABLE, decide(secure = false, encrypt = true, invalidated = false)) - assertEquals(LockAction.CLEAR_AND_DISABLE, decide(secure = false, encrypt = true, invalidated = true)) - assertEquals(LockAction.DISABLE_APP_LOCK, decide(secure = false, encrypt = false, invalidated = false)) - assertEquals(LockAction.DISABLE_APP_LOCK, decide(secure = false, encrypt = false, invalidated = true)) - // App-lock on, secure, key invalidated: clear+re-auth iff encrypted, else just re-auth. - assertEquals(LockAction.CLEAR_AND_REQUIRE_AUTH, decide(secure = true, encrypt = true, invalidated = true)) - assertEquals(LockAction.REQUIRE_AUTH, decide(secure = true, encrypt = false, invalidated = true)) - // App-lock on, secure, key valid: the common case — require auth (previously unpinned rows). - assertEquals(LockAction.REQUIRE_AUTH, decide(secure = true, encrypt = true, invalidated = false)) - assertEquals(LockAction.REQUIRE_AUTH, decide(secure = true, encrypt = false, invalidated = false)) } + + private data class Case( + val appLock: Boolean, + val encrypt: Boolean, + val secure: Boolean, + val invalidated: Boolean, + val expected: LockAction, + ) } diff --git a/app/src/test/kotlin/org/libremail/ui/lock/AppLockViewModelTest.kt b/app/src/test/kotlin/org/libremail/ui/lock/AppLockViewModelTest.kt index 61dcf56..49f16fd 100644 --- a/app/src/test/kotlin/org/libremail/ui/lock/AppLockViewModelTest.kt +++ b/app/src/test/kotlin/org/libremail/ui/lock/AppLockViewModelTest.kt @@ -1,10 +1,15 @@ // SPDX-License-Identifier: GPL-3.0-or-later package org.libremail.ui.lock +import android.content.Context import android.os.SystemClock +import android.security.keystore.KeyPermanentlyInvalidatedException +import android.security.keystore.UserNotAuthenticatedException import android.util.Log import androidx.work.Operation import com.google.common.util.concurrent.ListenableFuture +import io.mockk.coEvery +import io.mockk.coVerify import io.mockk.coVerifyOrder import io.mockk.every import io.mockk.mockk @@ -24,9 +29,13 @@ import org.junit.After import org.junit.Before import org.junit.Test import org.libremail.data.security.AppLockGate +import org.libremail.data.security.AppLockManager +import org.libremail.data.security.DatabaseKeyCipher import org.libremail.data.security.DatabaseKeyStore import org.libremail.data.security.KeyInvalidationPolicy import org.libremail.data.security.LockAction +import org.libremail.data.security.LockState +import org.libremail.data.security.PassphraseSession import org.libremail.data.settings.AppSettings import org.libremail.data.settings.SettingsRepository import org.libremail.data.sync.SyncScheduler @@ -40,12 +49,18 @@ import kotlin.test.assertIs * grace window survives Activity recreation — NOT a field the Activity-scoped ViewModel constructs * itself (which Back on the task root would drop on API 29/30). These tests exercise the synchronous * paths that delegate to the injected gate; the grace math itself is covered exhaustively — and - * deterministically — by AppLockGateTest. Broader ViewModel coverage is issue #100. + * deterministically — by AppLockGateTest. * - * The recovery-restart tests (#99) pin the ordering that makes the key-invalidation "clear + re-sync" - * safe: the re-sync enqueue must be durably persisted (its WorkManager Operation awaited) BEFORE the - * process is restarted, and a stuck enqueue must never wedge recovery. The separate-process relaunch - * itself is device-only; here we assert the ViewModel's orchestration around ProcessRestarter. + * The recovery-restart tests pin the ordering that makes the key-invalidation "clear + re-sync" safe: + * the re-sync enqueue must be durably persisted (its WorkManager Operation awaited) BEFORE the process + * is restarted, and a stuck enqueue must never wedge recovery. The separate-process relaunch itself is + * device-only; here we assert the ViewModel's orchestration around ProcessRestarter. + * + * Issue #100 broadens this to the ViewModel's security-critical branching: the `onForeground` + * [LockAction] dispatch (that DISABLE_APP_LOCK persists the setting, CLEAR_* set the pending flag and + * restart, and CLEAR_AND_REQUIRE_AUTH keeps app-lock on) and the `onAuthenticated` unlock/arm + * classification (OK / UNRECOVERABLE / RETRY), so a mutation that wipes user data or drops the lock is + * caught here rather than only on a device. */ @OptIn(ExperimentalCoroutinesApi::class) class AppLockViewModelTest { @@ -68,15 +83,21 @@ class AppLockViewModelTest { databaseKeyStore: DatabaseKeyStore = mockk(relaxed = true), syncScheduler: SyncScheduler = mockk(relaxed = true), processRestarter: ProcessRestarter = mockk(relaxed = true), + encryptCache: Boolean = false, + databaseKeyCipher: DatabaseKeyCipher = mockk(relaxed = true), + session: PassphraseSession = mockk(relaxed = true), + appLockManager: AppLockManager = mockk(relaxed = true), + context: Context = mockk(relaxed = true), ): AppLockViewModel { - every { settingsRepository.settings } returns flowOf(AppSettings(appLock = appLock)) + val appSettings = AppSettings(appLock = appLock, encryptCache = encryptCache) + every { settingsRepository.settings } returns flowOf(appSettings) return AppLockViewModel( - context = mockk(relaxed = true), + context = context, settingsRepository = settingsRepository, - appLockManager = mockk(relaxed = true), + appLockManager = appLockManager, databaseKeyStore = databaseKeyStore, - databaseKeyCipher = mockk(relaxed = true), - session = mockk(relaxed = true), + databaseKeyCipher = databaseKeyCipher, + session = session, syncScheduler = syncScheduler, processRestarter = processRestarter, gate = gate, @@ -151,6 +172,358 @@ class AppLockViewModelTest { verify { processRestarter.restart() } } + // --- onForeground: LockAction dispatch (issue #100) ------------------------------------------ + + @Test + fun `onForeground with app-lock off shows the app`() = runTest(dispatcher) { + mockkStatic(SystemClock::class) + every { SystemClock.elapsedRealtime() } returns FOREGROUND_AT + val vm = viewModel(gate = mockk(relaxed = true), appLock = false) + + vm.onForeground() + advanceUntilIdle() + + assertIs(vm.uiState.value) + } + + @Test + fun `onForeground DISABLE_APP_LOCK persists app-lock off and shows the app`() = runTest(dispatcher) { + val settingsRepository = mockk(relaxed = true) + val processRestarter = mockk(relaxed = true) + val vm = foregroundResolving( + LockAction.DISABLE_APP_LOCK, + settingsRepository = settingsRepository, + processRestarter = processRestarter, + ) + + vm.onForeground() + advanceUntilIdle() + + // The lock was silently dropped (device no longer secure, nothing encrypted to lose): the + // setting is persisted off and the app is shown, with no cache wipe / restart. + coVerify { settingsRepository.setAppLock(false) } + assertIs(vm.uiState.value) + verify(exactly = 0) { processRestarter.restart() } + } + + @Test + fun `onForeground CLEAR_AND_DISABLE clears, disables app-lock, then restarts in order`() = runTest(dispatcher) { + val (future, syncScheduler) = enqueueingScheduler() + val settingsRepository = mockk(relaxed = true) + val databaseKeyStore = mockk(relaxed = true) + val processRestarter = mockk(relaxed = true) + val vm = foregroundResolving( + LockAction.CLEAR_AND_DISABLE, + settingsRepository = settingsRepository, + databaseKeyStore = databaseKeyStore, + syncScheduler = syncScheduler, + processRestarter = processRestarter, + ) + + vm.onForeground() + advanceUntilIdle() + + // Crash-safe recovery order: record the wipe intent and drop the gate BEFORE the durable + // re-sync enqueue is awaited and the process is torn down. + coVerifyOrder { + databaseKeyStore.setClearPending() + settingsRepository.setAppLock(false) + syncScheduler.syncNow() + future.get(any(), any()) + processRestarter.restart() + } + } + + @Test + fun `onForeground CLEAR_AND_REQUIRE_AUTH clears and restarts but keeps app-lock on`() = runTest(dispatcher) { + val (future, syncScheduler) = enqueueingScheduler() + val settingsRepository = mockk(relaxed = true) + val databaseKeyStore = mockk(relaxed = true) + val processRestarter = mockk(relaxed = true) + val vm = foregroundResolving( + LockAction.CLEAR_AND_REQUIRE_AUTH, + settingsRepository = settingsRepository, + databaseKeyStore = databaseKeyStore, + syncScheduler = syncScheduler, + processRestarter = processRestarter, + ) + + vm.onForeground() + advanceUntilIdle() + + // Same clear + durable re-sync + restart, but app-lock stays ON: the wipe re-arms a fresh key + // on the next authentication, so setAppLock(false) must NOT be called. + coVerifyOrder { + databaseKeyStore.setClearPending() + future.get(any(), any()) + processRestarter.restart() + } + coVerify(exactly = 0) { settingsRepository.setAppLock(false) } + } + + @Test + fun `onForeground PROCEED shows the app without restarting`() = runTest(dispatcher) { + val processRestarter = mockk(relaxed = true) + val vm = foregroundResolving(LockAction.PROCEED, processRestarter = processRestarter) + + vm.onForeground() + advanceUntilIdle() + + assertIs(vm.uiState.value) + verify(exactly = 0) { processRestarter.restart() } + } + + @Test + fun `onForeground REQUIRE_AUTH advances the gate and publishes its locked decision`() = runTest(dispatcher) { + val gate = mockk(relaxed = true) + every { gate.state } returns LockState.LOCKED + val vm = foregroundResolving(LockAction.REQUIRE_AUTH, gate = gate) + + vm.onForeground() + advanceUntilIdle() + + verify { gate.onForeground(FOREGROUND_AT, appLockEnabled = true) } + assertIs(vm.uiState.value) + } + + // --- onAuthenticated: unlockOrArm / unwrapSealedPassphrase classification (issue #100) -------- + + @Test + fun `onAuthenticated with an already-unlocked session unlocks the gate without unwrapping`() = runTest(dispatcher) { + val gate = mockk(relaxed = true) + every { gate.state } returns LockState.UNLOCKED + val session = mockk(relaxed = true) + every { session.isUnlocked() } returns true + val databaseKeyStore = mockk(relaxed = true) + val vm = viewModel(gate = gate, session = session, databaseKeyStore = databaseKeyStore) + + vm.onAuthenticated() + advanceUntilIdle() + + verify { gate.onAuthenticated() } + assertIs(vm.uiState.value) + coVerify(exactly = 0) { databaseKeyStore.unlockWithAuth() } + coVerify(exactly = 0) { databaseKeyStore.sealWithAuth() } + } + + @Test + fun `onAuthenticated unwraps a sealed passphrase and unlocks the gate`() = runTest(dispatcher) { + val gate = mockk(relaxed = true) + every { gate.state } returns LockState.UNLOCKED + val session = mockk(relaxed = true) + every { session.isUnlocked() } returns false + val databaseKeyStore = mockk(relaxed = true) + coEvery { databaseKeyStore.hasAuthSealedPassphrase() } returns true + val databaseKeyCipher = mockk(relaxed = true) + every { databaseKeyCipher.hasKey() } returns true + val vm = viewModel( + gate = gate, + session = session, + databaseKeyStore = databaseKeyStore, + databaseKeyCipher = databaseKeyCipher, + ) + + vm.onAuthenticated() + advanceUntilIdle() + + coVerify { databaseKeyStore.unlockWithAuth() } + verify { gate.onAuthenticated() } + assertIs(vm.uiState.value) + } + + @Test + fun `onAuthenticated clears the cache when the auth-bound key was deleted`() = runTest(dispatcher) { + stubLog() + val (future, syncScheduler) = enqueueingScheduler() + val session = mockk(relaxed = true) + every { session.isUnlocked() } returns false + val databaseKeyStore = mockk(relaxed = true) + coEvery { databaseKeyStore.hasAuthSealedPassphrase() } returns true + val databaseKeyCipher = mockk(relaxed = true) + every { databaseKeyCipher.hasKey() } returns false // key gone entirely -> unrecoverable + val processRestarter = mockk(relaxed = true) + val vm = viewModel( + gate = mockk(relaxed = true), + session = session, + databaseKeyStore = databaseKeyStore, + databaseKeyCipher = databaseKeyCipher, + syncScheduler = syncScheduler, + processRestarter = processRestarter, + ) + + vm.onAuthenticated() + advanceUntilIdle() + + // Unrecoverable: never attempt the unwrap; wipe the cache and restart (app-lock stays on). + coVerify(exactly = 0) { databaseKeyStore.unlockWithAuth() } + coVerifyOrder { + databaseKeyStore.setClearPending() + future.get(any(), any()) + processRestarter.restart() + } + } + + @Test + fun `onAuthenticated clears the cache when the auth-bound key was permanently invalidated`() = runTest(dispatcher) { + stubLog() + val (_, syncScheduler) = enqueueingScheduler() + val session = mockk(relaxed = true) + every { session.isUnlocked() } returns false + val databaseKeyStore = mockk(relaxed = true) + coEvery { databaseKeyStore.hasAuthSealedPassphrase() } returns true + coEvery { databaseKeyStore.unlockWithAuth() } throws mockk(relaxed = true) + val databaseKeyCipher = mockk(relaxed = true) + every { databaseKeyCipher.hasKey() } returns true + val processRestarter = mockk(relaxed = true) + val vm = viewModel( + gate = mockk(relaxed = true), + session = session, + databaseKeyStore = databaseKeyStore, + databaseKeyCipher = databaseKeyCipher, + syncScheduler = syncScheduler, + processRestarter = processRestarter, + ) + + vm.onAuthenticated() + advanceUntilIdle() + + coVerify { databaseKeyStore.setClearPending() } + verify { processRestarter.restart() } + } + + @Test + fun `onAuthenticated re-locks for a retry when the auth window elapsed`() = runTest(dispatcher) { + stubLog() + val gate = mockk(relaxed = true) + every { gate.state } returns LockState.LOCKED + val session = mockk(relaxed = true) + every { session.isUnlocked() } returns false + val databaseKeyStore = mockk(relaxed = true) + coEvery { databaseKeyStore.hasAuthSealedPassphrase() } returns true + coEvery { databaseKeyStore.unlockWithAuth() } throws mockk(relaxed = true) + val databaseKeyCipher = mockk(relaxed = true) + every { databaseKeyCipher.hasKey() } returns true + val processRestarter = mockk(relaxed = true) + val vm = viewModel( + gate = gate, + session = session, + databaseKeyStore = databaseKeyStore, + databaseKeyCipher = databaseKeyCipher, + processRestarter = processRestarter, + ) + + vm.onAuthenticated() + advanceUntilIdle() + + // A lapsed auth window is transient: re-lock and let the user retry — never wipe the cache. + verify { gate.lock() } + assertIs(vm.uiState.value) + verify(exactly = 0) { processRestarter.restart() } + } + + @Test + fun `onAuthenticated re-locks for a retry on an ambiguous unwrap failure`() = runTest(dispatcher) { + stubLog() + val gate = mockk(relaxed = true) + every { gate.state } returns LockState.LOCKED + val session = mockk(relaxed = true) + every { session.isUnlocked() } returns false + val databaseKeyStore = mockk(relaxed = true) + coEvery { databaseKeyStore.hasAuthSealedPassphrase() } returns true + coEvery { databaseKeyStore.unlockWithAuth() } throws IllegalStateException("corrupt sealed blob") + val databaseKeyCipher = mockk(relaxed = true) + every { databaseKeyCipher.hasKey() } returns true + val processRestarter = mockk(relaxed = true) + val vm = viewModel( + gate = gate, + session = session, + databaseKeyStore = databaseKeyStore, + databaseKeyCipher = databaseKeyCipher, + processRestarter = processRestarter, + ) + + vm.onAuthenticated() + advanceUntilIdle() + + // An ambiguous error must NOT wipe the cache on an otherwise-successful auth: re-lock + retry. + verify { gate.lock() } + verify(exactly = 0) { processRestarter.restart() } + } + + @Test + fun `onAuthenticated with no seal and encryption off unlocks without arming`() = runTest(dispatcher) { + val gate = mockk(relaxed = true) + every { gate.state } returns LockState.UNLOCKED + val session = mockk(relaxed = true) + every { session.isUnlocked() } returns false + val databaseKeyStore = mockk(relaxed = true) + coEvery { databaseKeyStore.hasAuthSealedPassphrase() } returns false + val vm = viewModel( + gate = gate, + session = session, + databaseKeyStore = databaseKeyStore, + encryptCache = false, + ) + + vm.onAuthenticated() + advanceUntilIdle() + + // App-lock is a pure UI gate this session: there is no encrypted cache to arm. + coVerify(exactly = 0) { databaseKeyStore.sealWithAuth() } + verify { gate.onAuthenticated() } + assertIs(vm.uiState.value) + } + + @Test + fun `onAuthenticated arms a fresh auth seal when encryption is on and none exists`() = runTest(dispatcher) { + val gate = mockk(relaxed = true) + every { gate.state } returns LockState.UNLOCKED + val session = mockk(relaxed = true) + every { session.isUnlocked() } returns false + val databaseKeyStore = mockk(relaxed = true) + coEvery { databaseKeyStore.hasAuthSealedPassphrase() } returns false + val vm = viewModel( + gate = gate, + session = session, + databaseKeyStore = databaseKeyStore, + encryptCache = true, + ) + + vm.onAuthenticated() + advanceUntilIdle() + + coVerify { databaseKeyStore.sealWithAuth() } + verify { gate.onAuthenticated() } + assertIs(vm.uiState.value) + } + + @Test + fun `onAuthenticated re-locks for a retry when arming the auth seal fails`() = runTest(dispatcher) { + stubLog() + val gate = mockk(relaxed = true) + every { gate.state } returns LockState.LOCKED + val session = mockk(relaxed = true) + every { session.isUnlocked() } returns false + val databaseKeyStore = mockk(relaxed = true) + coEvery { databaseKeyStore.hasAuthSealedPassphrase() } returns false + coEvery { databaseKeyStore.sealWithAuth() } throws IllegalStateException("keystore busy") + val processRestarter = mockk(relaxed = true) + val vm = viewModel( + gate = gate, + session = session, + databaseKeyStore = databaseKeyStore, + encryptCache = true, + processRestarter = processRestarter, + ) + + vm.onAuthenticated() + advanceUntilIdle() + + verify { gate.lock() } + assertIs(vm.uiState.value) + verify(exactly = 0) { processRestarter.restart() } + } + /** A [SyncScheduler] whose `syncNow()` returns an [Operation] whose result future can be stubbed. */ private fun enqueueingScheduler(): Pair, SyncScheduler> { val future = mockk>() @@ -184,4 +557,42 @@ class AppLockViewModelTest { processRestarter = processRestarter, ) } + + /** + * Builds a ViewModel whose next `onForeground()` resolves to [action] by stubbing the pure decision + * table directly (its own exhaustive coverage is KeyInvalidationPolicyTest) plus the Android statics + * `onForeground` touches, so each [LockAction] branch is driven without reproducing device state. + */ + private fun foregroundResolving( + action: LockAction, + gate: AppLockGate = mockk(relaxed = true), + settingsRepository: SettingsRepository = mockk(relaxed = true), + databaseKeyStore: DatabaseKeyStore = mockk(relaxed = true), + syncScheduler: SyncScheduler = mockk(relaxed = true), + processRestarter: ProcessRestarter = mockk(relaxed = true), + ): AppLockViewModel { + mockkStatic(SystemClock::class) + every { SystemClock.elapsedRealtime() } returns FOREGROUND_AT + stubLog() + mockkObject(KeyInvalidationPolicy) + every { KeyInvalidationPolicy.decide(any(), any(), any(), any()) } returns action + return viewModel( + gate = gate, + settingsRepository = settingsRepository, + databaseKeyStore = databaseKeyStore, + syncScheduler = syncScheduler, + processRestarter = processRestarter, + ) + } + + /** android.util.Log is a no-op stub that throws "not mocked" in JVM tests; the recovery paths log. */ + private fun stubLog() { + mockkStatic(Log::class) + every { Log.w(any(), any()) } returns 0 + every { Log.w(any(), any(), any()) } returns 0 + } + + private companion object { + const val FOREGROUND_AT = 1_000L + } } diff --git a/app/src/test/kotlin/org/libremail/ui/settings/SettingsViewModelTest.kt b/app/src/test/kotlin/org/libremail/ui/settings/SettingsViewModelTest.kt new file mode 100644 index 0000000..00e50c6 --- /dev/null +++ b/app/src/test/kotlin/org/libremail/ui/settings/SettingsViewModelTest.kt @@ -0,0 +1,144 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.ui.settings + +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.coVerifyOrder +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.libremail.R +import org.libremail.data.security.AppLockManager +import org.libremail.data.security.DatabaseKeyStore +import org.libremail.data.settings.AppSettings +import org.libremail.data.settings.SettingsRepository +import org.libremail.domain.repository.AccountRepository +import kotlin.test.assertEquals +import kotlin.test.assertNull + +/** + * Covers [SettingsViewModel.setAppLock]'s security-critical branches (issue #100): enabling is rejected + * without a secure device lock; disabling reseals the cache passphrase under the non-auth master key + * BEFORE dropping the gate, and keeps app-lock on if that reseal fails (so the passphrase is never + * stranded). JVM-testable with the repo's existing MockK pattern — no device needed. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class SettingsViewModelTest { + + private val dispatcher = UnconfinedTestDispatcher() + + @Before + fun setUp() = Dispatchers.setMain(dispatcher) + + @After + fun tearDown() = Dispatchers.resetMain() + + @Test + fun `enabling app-lock without a secure device is rejected and does not persist`() = runTest(dispatcher) { + val appLockManager = mockk() + every { appLockManager.isDeviceSecure() } returns false + val settingsRepository = mockk(relaxed = true) + val vm = viewModel(appLockManager = appLockManager, settingsRepository = settingsRepository) + + vm.setAppLock(true) + advanceUntilIdle() + + // No secure lock means nothing to authenticate against: reject with a message, persist nothing. + assertEquals(R.string.app_lock_needs_device_lock, vm.appLockMessage.value) + coVerify(exactly = 0) { settingsRepository.setAppLock(any()) } + } + + @Test + fun `enabling app-lock on a secure device persists the setting`() = runTest(dispatcher) { + val appLockManager = mockk() + every { appLockManager.isDeviceSecure() } returns true + val settingsRepository = mockk(relaxed = true) + val vm = viewModel(appLockManager = appLockManager, settingsRepository = settingsRepository) + + vm.setAppLock(true) + advanceUntilIdle() + + coVerify { settingsRepository.setAppLock(true) } + assertNull(vm.appLockMessage.value) + } + + @Test + fun `disabling app-lock reseals under the master key before dropping the gate`() = runTest(dispatcher) { + val databaseKeyStore = mockk(relaxed = true) + coEvery { databaseKeyStore.hasAuthSealedPassphrase() } returns true + val settingsRepository = mockk(relaxed = true) + val vm = viewModel(databaseKeyStore = databaseKeyStore, settingsRepository = settingsRepository) + + vm.setAppLock(false) + advanceUntilIdle() + + // Reseal so the cache opens without auth again, THEN drop the gate — reversing the order would + // leave the next launch unable to open a still-auth-sealed cache. + coVerifyOrder { + databaseKeyStore.sealWithMaster() + settingsRepository.setAppLock(false) + } + } + + @Test + fun `disabling app-lock keeps the lock on when resealing fails`() = runTest(dispatcher) { + val databaseKeyStore = mockk(relaxed = true) + coEvery { databaseKeyStore.hasAuthSealedPassphrase() } returns true + coEvery { databaseKeyStore.sealWithMaster() } throws IllegalStateException("keystore busy") + val settingsRepository = mockk(relaxed = true) + val vm = viewModel(databaseKeyStore = databaseKeyStore, settingsRepository = settingsRepository) + + vm.setAppLock(false) + advanceUntilIdle() + + // Resealing failed: surface a message and keep app-lock ON rather than strand the passphrase + // under a gate we just dropped. + assertEquals(R.string.app_lock_disable_failed, vm.appLockMessage.value) + coVerify(exactly = 0) { settingsRepository.setAppLock(false) } + } + + @Test + fun `disabling app-lock with no auth seal just drops the gate`() = runTest(dispatcher) { + val databaseKeyStore = mockk(relaxed = true) + coEvery { databaseKeyStore.hasAuthSealedPassphrase() } returns false + val settingsRepository = mockk(relaxed = true) + val vm = viewModel(databaseKeyStore = databaseKeyStore, settingsRepository = settingsRepository) + + vm.setAppLock(false) + advanceUntilIdle() + + // Nothing auth-sealed to reseal: skip the master reseal and simply drop the gate. + coVerify(exactly = 0) { databaseKeyStore.sealWithMaster() } + coVerify { settingsRepository.setAppLock(false) } + } + + private fun viewModel( + appLockManager: AppLockManager = mockk(relaxed = true), + databaseKeyStore: DatabaseKeyStore = mockk(relaxed = true), + settingsRepository: SettingsRepository = mockk(relaxed = true), + ): SettingsViewModel { + every { settingsRepository.settings } returns flowOf(AppSettings()) + every { settingsRepository.contactsPermissionRequested } returns flowOf(false) + val accountRepository = mockk(relaxed = true) + every { accountRepository.observeAccounts() } returns flowOf(emptyList()) + return SettingsViewModel( + accountRepository = accountRepository, + settingsRepository = settingsRepository, + appLockManager = appLockManager, + databaseKeyStore = databaseKeyStore, + batteryOptimizationManager = mockk(relaxed = true), + contactsPermissionManager = mockk(relaxed = true), + syncScheduler = mockk(relaxed = true), + ) + } +}