test(security): cover the app-lock security core (seal exchange, unlock classification, policy table) #144
@@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user