test(security): cover the app-lock security core (seal exchange, unlock classification, policy table) #144

Merged
JMR-dev merged 2 commits from test-applock-security-core into main 2026-07-02 16:55:51 +00:00
5 changed files with 893 additions and 40 deletions
@@ -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<Preferences> = 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>): String? = context.dbKeyDataStore.data.first()[key]
private suspend fun read(key: Preferences.Key<String>): 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
}
@@ -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:<plain>`; [authCipher] models the auth-bound seal as
* `a:<plain>`. 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<KeystoreCrypto>(relaxed = true)
private val authCipher = mockk<DatabaseKeyCipher>(relaxed = true)
private val session = PassphraseSession()
@Before
fun setUp() {
every { crypto.encrypt(any()) } answers { "m:" + firstArg<String>() }
every { crypto.decrypt(any()) } answers { firstArg<String>().removePrefix("m:") }
every { authCipher.encrypt(any()) } answers { "a:" + firstArg<String>() }
every { authCipher.decrypt(any()) } answers { firstArg<String>().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<IllegalStateException> { 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<Preferences> {
private val flow = MutableStateFlow(emptyPreferences())
override val data: Flow<Preferences> = flow.asStateFlow()
override suspend fun updateData(transform: suspend (Preferences) -> Preferences): Preferences {
val updated = transform(flow.value)
flow.value = updated
return updated
}
}
@@ -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,
)
}
@@ -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<AppLockUiState.Unlocked>(vm.uiState.value)
}
@Test
fun `onForeground DISABLE_APP_LOCK persists app-lock off and shows the app`() = runTest(dispatcher) {
val settingsRepository = mockk<SettingsRepository>(relaxed = true)
val processRestarter = mockk<ProcessRestarter>(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<AppLockUiState.Unlocked>(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<SettingsRepository>(relaxed = true)
val databaseKeyStore = mockk<DatabaseKeyStore>(relaxed = true)
val processRestarter = mockk<ProcessRestarter>(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<SettingsRepository>(relaxed = true)
val databaseKeyStore = mockk<DatabaseKeyStore>(relaxed = true)
val processRestarter = mockk<ProcessRestarter>(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<ProcessRestarter>(relaxed = true)
val vm = foregroundResolving(LockAction.PROCEED, processRestarter = processRestarter)
vm.onForeground()
advanceUntilIdle()
assertIs<AppLockUiState.Unlocked>(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<AppLockGate>(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<AppLockUiState.Locked>(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<AppLockGate>(relaxed = true)
every { gate.state } returns LockState.UNLOCKED
val session = mockk<PassphraseSession>(relaxed = true)
every { session.isUnlocked() } returns true
val databaseKeyStore = mockk<DatabaseKeyStore>(relaxed = true)
val vm = viewModel(gate = gate, session = session, databaseKeyStore = databaseKeyStore)
vm.onAuthenticated()
advanceUntilIdle()
verify { gate.onAuthenticated() }
assertIs<AppLockUiState.Unlocked>(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<AppLockGate>(relaxed = true)
every { gate.state } returns LockState.UNLOCKED
val session = mockk<PassphraseSession>(relaxed = true)
every { session.isUnlocked() } returns false
val databaseKeyStore = mockk<DatabaseKeyStore>(relaxed = true)
coEvery { databaseKeyStore.hasAuthSealedPassphrase() } returns true
val databaseKeyCipher = mockk<DatabaseKeyCipher>(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<AppLockUiState.Unlocked>(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<PassphraseSession>(relaxed = true)
every { session.isUnlocked() } returns false
val databaseKeyStore = mockk<DatabaseKeyStore>(relaxed = true)
coEvery { databaseKeyStore.hasAuthSealedPassphrase() } returns true
val databaseKeyCipher = mockk<DatabaseKeyCipher>(relaxed = true)
every { databaseKeyCipher.hasKey() } returns false // key gone entirely -> unrecoverable
val processRestarter = mockk<ProcessRestarter>(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<PassphraseSession>(relaxed = true)
every { session.isUnlocked() } returns false
val databaseKeyStore = mockk<DatabaseKeyStore>(relaxed = true)
coEvery { databaseKeyStore.hasAuthSealedPassphrase() } returns true
coEvery { databaseKeyStore.unlockWithAuth() } throws mockk<KeyPermanentlyInvalidatedException>(relaxed = true)
val databaseKeyCipher = mockk<DatabaseKeyCipher>(relaxed = true)
every { databaseKeyCipher.hasKey() } returns true
val processRestarter = mockk<ProcessRestarter>(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<AppLockGate>(relaxed = true)
every { gate.state } returns LockState.LOCKED
val session = mockk<PassphraseSession>(relaxed = true)
every { session.isUnlocked() } returns false
val databaseKeyStore = mockk<DatabaseKeyStore>(relaxed = true)
coEvery { databaseKeyStore.hasAuthSealedPassphrase() } returns true
coEvery { databaseKeyStore.unlockWithAuth() } throws mockk<UserNotAuthenticatedException>(relaxed = true)
val databaseKeyCipher = mockk<DatabaseKeyCipher>(relaxed = true)
every { databaseKeyCipher.hasKey() } returns true
val processRestarter = mockk<ProcessRestarter>(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<AppLockUiState.Locked>(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<AppLockGate>(relaxed = true)
every { gate.state } returns LockState.LOCKED
val session = mockk<PassphraseSession>(relaxed = true)
every { session.isUnlocked() } returns false
val databaseKeyStore = mockk<DatabaseKeyStore>(relaxed = true)
coEvery { databaseKeyStore.hasAuthSealedPassphrase() } returns true
coEvery { databaseKeyStore.unlockWithAuth() } throws IllegalStateException("corrupt sealed blob")
val databaseKeyCipher = mockk<DatabaseKeyCipher>(relaxed = true)
every { databaseKeyCipher.hasKey() } returns true
val processRestarter = mockk<ProcessRestarter>(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<AppLockGate>(relaxed = true)
every { gate.state } returns LockState.UNLOCKED
val session = mockk<PassphraseSession>(relaxed = true)
every { session.isUnlocked() } returns false
val databaseKeyStore = mockk<DatabaseKeyStore>(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<AppLockUiState.Unlocked>(vm.uiState.value)
}
@Test
fun `onAuthenticated arms a fresh auth seal when encryption is on and none exists`() = runTest(dispatcher) {
val gate = mockk<AppLockGate>(relaxed = true)
every { gate.state } returns LockState.UNLOCKED
val session = mockk<PassphraseSession>(relaxed = true)
every { session.isUnlocked() } returns false
val databaseKeyStore = mockk<DatabaseKeyStore>(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<AppLockUiState.Unlocked>(vm.uiState.value)
}
@Test
fun `onAuthenticated re-locks for a retry when arming the auth seal fails`() = runTest(dispatcher) {
stubLog()
val gate = mockk<AppLockGate>(relaxed = true)
every { gate.state } returns LockState.LOCKED
val session = mockk<PassphraseSession>(relaxed = true)
every { session.isUnlocked() } returns false
val databaseKeyStore = mockk<DatabaseKeyStore>(relaxed = true)
coEvery { databaseKeyStore.hasAuthSealedPassphrase() } returns false
coEvery { databaseKeyStore.sealWithAuth() } throws IllegalStateException("keystore busy")
val processRestarter = mockk<ProcessRestarter>(relaxed = true)
val vm = viewModel(
gate = gate,
session = session,
databaseKeyStore = databaseKeyStore,
encryptCache = true,
processRestarter = processRestarter,
)
vm.onAuthenticated()
advanceUntilIdle()
verify { gate.lock() }
assertIs<AppLockUiState.Locked>(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<ListenableFuture<Operation.State.SUCCESS>, SyncScheduler> {
val future = mockk<ListenableFuture<Operation.State.SUCCESS>>()
@@ -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<String>(), any<String>()) } returns 0
every { Log.w(any<String>(), any<String>(), any<Throwable>()) } returns 0
}
private companion object {
const val FOREGROUND_AT = 1_000L
}
}
@@ -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<AppLockManager>()
every { appLockManager.isDeviceSecure() } returns false
val settingsRepository = mockk<SettingsRepository>(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<AppLockManager>()
every { appLockManager.isDeviceSecure() } returns true
val settingsRepository = mockk<SettingsRepository>(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<DatabaseKeyStore>(relaxed = true)
coEvery { databaseKeyStore.hasAuthSealedPassphrase() } returns true
val settingsRepository = mockk<SettingsRepository>(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<DatabaseKeyStore>(relaxed = true)
coEvery { databaseKeyStore.hasAuthSealedPassphrase() } returns true
coEvery { databaseKeyStore.sealWithMaster() } throws IllegalStateException("keystore busy")
val settingsRepository = mockk<SettingsRepository>(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<DatabaseKeyStore>(relaxed = true)
coEvery { databaseKeyStore.hasAuthSealedPassphrase() } returns false
val settingsRepository = mockk<SettingsRepository>(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<AccountRepository>(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),
)
}
}