fix(security): harden app-lock + encrypted-cache flows (PR #45 review)

Addresses 14 of the 15 confirmed findings from the max-effort review of the
screen-lock app gate. The remaining one (accounts/credentials share the
auth-bound cache DB) needs a device-tested Room migration and is filed
separately; its blast radius is reduced here by eliminating the spurious wipes.

- Cold-start deadlock: LibreMailApplication injects AccountRepository lazily so
  the Room DB is never built on the main thread before unlock.
- Passphrase source of truth: DatabaseKeyStore.resolvePassphrase() keys off
  which seal exists, not the app-lock setting; passphrase() refuses to mint a
  master key while an auth seal exists.
- Toggle-order strand: disabling app-lock reseals under the master key whenever
  an auth seal exists (not gated on the encryptCache setting).
- Crash-safe clear protocol: wipe + reset seals, then clear the flag last; set
  clear-pending before flipping app-lock off.
- isInvalidated(): treats a lapsed auth window (UserNotAuthenticated) as valid,
  and onForeground short-circuits when app-lock is off.
- unwrapSealedPassphrase: classifies all decrypt failures — no crash after a
  successful auth.
- Headless entry points: SyncWorker/SendWorker/IdleService fail fast via
  EncryptedCacheGuard instead of blocking DB construction while locked.
- sealWithMaster: deletes the orphaned auth key (no spurious later wipe).
- Lock-bypass race: AppLockGate ignores a background recorded after a foreground
  pass began; the ViewModel captures the foreground timestamp synchronously.
- FLAG_SECURE: set while app-lock is on (recents/screenshot protection).
- Resume + re-lock: the gate covers content with an opaque overlay instead of
  removing it, so no stale frame renders and in-progress state (nav, drafts)
  survives re-lock.
- Retry feedback: lock emissions carry a nonce so a retry updates the UI.

Tests: AppLockGate stale-foreground race cases + an exhaustive
KeyInvalidationPolicy table. Fast gate green + androidTest compiles.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-01 22:29:48 -05:00
co-authored by Claude Fable 5
parent 4a139faf61
commit f66ec3d7fb
16 changed files with 364 additions and 85 deletions
@@ -4,6 +4,7 @@ package org.libremail
import android.app.Application
import androidx.hilt.work.HiltWorkerFactory
import androidx.work.Configuration
import dagger.Lazy
import dagger.hilt.android.HiltAndroidApp
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
@@ -33,7 +34,10 @@ class LibreMailApplication :
@Inject lateinit var settingsRepository: SettingsRepository
@Inject lateinit var accountRepository: AccountRepository
// Lazy: resolving AccountRepository constructs the Room database, which — with app-lock + encrypted
// cache on — blocks until the user authenticates. Keeping it lazy means the DB is built off the main
// thread inside the collector below (never during onCreate), so the app never deadlocks at launch.
@Inject lateinit var accountRepository: Lazy<AccountRepository>
@Inject lateinit var idlePushManager: IdlePushManager
@@ -70,7 +74,7 @@ class LibreMailApplication :
appScope.launch {
combine(
settingsRepository.settings.map { it.pushIdle },
accountRepository.observeAccounts().map { it.isNotEmpty() },
accountRepository.get().observeAccounts().map { it.isNotEmpty() },
) { pushEnabled, hasAccounts -> pushEnabled && hasAccounts }
.distinctUntilChanged()
.collect { active ->
@@ -6,6 +6,7 @@ import android.content.Intent
import android.content.pm.PackageManager
import android.os.Build
import android.os.Bundle
import android.view.WindowManager
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
@@ -18,7 +19,11 @@ import androidx.compose.ui.platform.LocalContext
import androidx.core.content.ContextCompat
import androidx.fragment.app.FragmentActivity
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.lifecycleScope
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import org.libremail.data.settings.SettingsRepository
import org.libremail.ui.LibreMailApp
import org.libremail.ui.compose.ComposePrefill
@@ -51,6 +56,18 @@ class MainActivity : FragmentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
// Block screenshots and the recents-switcher snapshot while app-lock is on — the Compose gate
// can't stop the system's task snapshot (captured around background). Gated on the setting
// because FLAG_SECURE also blocks the user's own screenshots.
lifecycleScope.launch {
settingsRepository.settings.map { it.appLock }.distinctUntilChanged().collect { secure ->
if (secure) {
window.addFlags(WindowManager.LayoutParams.FLAG_SECURE)
} else {
window.clearFlags(WindowManager.LayoutParams.FLAG_SECURE)
}
}
}
// Only on a fresh launch — on a config-change recreation the NavHost restores the compose
// destination itself, so re-parsing the (unchanged) intent would open a duplicate.
if (savedInstanceState == null) {
@@ -24,18 +24,25 @@ class AppLockGate(private val graceMillis: Long = DEFAULT_GRACE_MILLIS) {
private var backgroundedAt: Long? = null
/**
* Recompute the lock state when the app comes to the foreground. [now] is a monotonic-ish epoch
* in milliseconds. Returns the resulting state.
* Recompute the lock state when the app comes to the foreground. [now] is a monotonic-ish epoch in
* milliseconds, captured synchronously when the foreground event fires (so a background recorded
* during an async foreground pass can't be mistaken for a within-grace return). Returns the state.
*/
fun onForeground(now: Long, appLockEnabled: Boolean): LockState {
val bgAt = backgroundedAt
state = when {
!appLockEnabled -> LockState.UNLOCKED
state == LockState.LOCKED -> LockState.LOCKED
backgroundedAt == null -> LockState.UNLOCKED
withinGrace(now) -> LockState.UNLOCKED
bgAt == null -> LockState.UNLOCKED
// A background recorded AFTER this pass began (now < bgAt): the app has since gone back to
// the background, so re-lock and KEEP the marker for the genuine return to evaluate.
now < bgAt -> LockState.LOCKED
now - bgAt <= graceMillis -> LockState.UNLOCKED
else -> LockState.LOCKED
}
backgroundedAt = null
// Consume the marker only for a genuine (non-stale) foreground, so a concurrent background is
// never silently erased — that erasure is what would let the next foreground skip re-locking.
if (bgAt == null || now >= bgAt) backgroundedAt = null
return state
}
@@ -55,11 +62,6 @@ class AppLockGate(private val graceMillis: Long = DEFAULT_GRACE_MILLIS) {
state = LockState.LOCKED
}
private fun withinGrace(now: Long): Boolean {
val since = backgroundedAt ?: return false
return now - since in 0..graceMillis
}
companion object {
/** Time the user can leave the app before re-authentication is required. */
const val DEFAULT_GRACE_MILLIS: Long = 30_000
@@ -5,6 +5,7 @@ import android.os.Build
import android.security.keystore.KeyGenParameterSpec
import android.security.keystore.KeyPermanentlyInvalidatedException
import android.security.keystore.KeyProperties
import android.security.keystore.UserNotAuthenticatedException
import android.util.Base64
import android.util.Log
import java.security.KeyStore
@@ -73,9 +74,10 @@ class DatabaseKeyCipher @Inject constructor() {
}
/**
* True when the key exists but has been permanently invalidated (a new biometric was enrolled or
* the device lock was removed). Detected by attempting to initialize a cipher, which fails fast
* without needing an auth window.
* True only when the key exists AND has been permanently invalidated (a new biometric was enrolled
* or the device lock was removed). A merely-lapsed auth window ([UserNotAuthenticatedException]) is
* NOT invalidation and returns false, so a routine foreground pass — which calls this before any
* authentication — never mistakes an unauthenticated key for one that must trigger a cache wipe.
*/
fun isInvalidated(): Boolean {
val key = existingKey() ?: return false
@@ -85,6 +87,15 @@ class DatabaseKeyCipher @Inject constructor() {
} catch (e: KeyPermanentlyInvalidatedException) {
Log.d(TAG, "auth-bound database key invalidated", e)
true
} catch (e: UserNotAuthenticatedException) {
// Valid key, just outside its time-bound auth window — not invalidated.
Log.d(TAG, "auth-bound key outside its auth window; not invalidated", e)
false
} catch (e: Exception) {
// Never let a validity probe crash the foreground pass; a real decrypt later surfaces any
// genuine problem. Treat an unknown probe failure as "not invalidated" (don't wipe).
Log.d(TAG, "auth-bound key validity probe failed; treating as valid", e)
false
}
}
@@ -18,6 +18,9 @@ import javax.inject.Singleton
private val Context.dbKeyDataStore: DataStore<Preferences> by preferencesDataStore(name = "libremail_dbkey")
/** Which Keystore seal currently protects the cache passphrase (or [NONE] before first use). */
enum class SealState { NONE, MASTER, AUTH }
/**
* Supplies the SQLCipher passphrase for the opt-in encrypted cache. A random 256-bit key is
* generated once and persisted only as ciphertext — sealed by a non-exportable Android Keystore key
@@ -41,17 +44,44 @@ class DatabaseKeyStore @Inject constructor(
) {
private val generationLock = Mutex()
/**
* 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
* out of sync with the seals. This is the single source of truth `DatabaseModule` uses:
* - [SealState.AUTH]: the value lives only in [PassphraseSession] after the user authenticates;
* wait for it. Never re-derive or regenerate an auth-sealed passphrase.
* - [SealState.MASTER]: unwrap it with the non-auth master key (no authentication needed).
* - [SealState.NONE]: first use. When app-lock is off, generate + master-seal a fresh key; when
* app-lock is on the arming flow ([sealWithAuth]) mints and unlocks it right after auth, so wait.
*/
suspend fun resolvePassphrase(appLockEnabled: Boolean): String = when (sealState()) {
SealState.AUTH -> session.current() ?: session.await()
SealState.MASTER -> masterSealed() ?: error("master-sealed passphrase failed to decrypt")
SealState.NONE -> if (appLockEnabled) session.current() ?: session.await() else passphrase()
}
/** Which seal currently protects the passphrase (or [SealState.NONE] before first use). */
suspend fun sealState(): SealState = when {
read(SEALED_AUTH) != null -> SealState.AUTH
read(SEALED_MASTER) != null -> SealState.MASTER
else -> SealState.NONE
}
/**
* App-lock OFF path: returns the passphrase sealed by the master key, generating and sealing it
* on first use. This is the original auto-unwrap behavior and must only be used when app-lock is
* disabled (when it is enabled the master-sealed copy is intentionally removed).
* on first use. Refuses to mint a fresh key while an auth seal exists — doing so would strand the
* real (auth-sealed) key and leave the DB encrypted under a passphrase we could never reproduce.
*/
suspend fun passphrase(): String {
masterSealed()?.let { return it }
return generationLock.withLock {
// Re-check inside the lock so a concurrent first-caller doesn't generate a second key
// (which would leave a DB encrypted under a key we then overwrite and can't reproduce).
masterSealed() ?: generateAndSealMaster()
masterSealed()?.let { return@withLock it }
check(read(SEALED_AUTH) == null) {
"refusing to generate a master passphrase while an auth-sealed passphrase exists"
}
generateAndSealMaster()
}
}
@@ -97,6 +127,9 @@ class DatabaseKeyStore @Inject constructor(
it[SEALED_MASTER] = crypto.encrypt(plain)
it.remove(SEALED_AUTH)
}
// The auth-bound key is now unused. Delete it so a later invalidation of this orphaned key
// can't trigger a spurious cache wipe, and its stale probe can't crash the foreground pass.
authCipher.deleteKey()
session.lock()
}
@@ -117,7 +150,7 @@ class DatabaseKeyStore @Inject constructor(
/**
* Persist that the encrypted cache must be wiped on the next cold start. The actual file deletion
* happens in `DatabaseModule.provideDatabase` (via [consumeClearPending]) BEFORE Room opens the
* happens in `DatabaseModule.provideDatabase` (guarded by [isClearPending]) BEFORE Room opens the
* database, so there is never an open connection whose backing file is deleted underneath it —
* the corruption-safe way to "clear + re-sync" after a screen-lock change invalidates the key.
*/
@@ -125,11 +158,16 @@ class DatabaseKeyStore @Inject constructor(
context.dbKeyDataStore.edit { it[CLEAR_PENDING] = true }
}
/** Read-and-clear the clear-pending flag. Returns true if the cache should be wiped now. */
suspend fun consumeClearPending(): Boolean {
val pending = context.dbKeyDataStore.data.first()[CLEAR_PENDING] == true
if (pending) context.dbKeyDataStore.edit { it.remove(CLEAR_PENDING) }
return pending
/**
* True if the cache should be wiped at this cold start. Does NOT clear the flag: the caller must
* 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
/** Clear the wipe flag. Call ONLY after the wipe + [resetSealedPassphrase] have completed. */
suspend fun clearClearPending() {
context.dbKeyDataStore.edit { it.remove(CLEAR_PENDING) }
}
private suspend fun masterSealed(): String? = read(SEALED_MASTER)?.let { crypto.decrypt(it) }
@@ -0,0 +1,31 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.data.security
import kotlinx.coroutines.flow.first
import org.libremail.data.settings.SettingsRepository
import javax.inject.Inject
import javax.inject.Singleton
/**
* Tells UI-less entry points (background sync/push/send) whether opening the Room cache would block
* on the user authenticating. When app-lock AND encrypted-cache are on, the SQLCipher passphrase is
* sealed by an auth-bound key and only lives in [PassphraseSession] after an unlock — so a worker
* that constructs a DAO before then would park a thread indefinitely inside `provideDatabase`.
*
* Depends only on DataStore ([SettingsRepository]) and in-memory state ([PassphraseSession]) — never
* on the Room database — so it is safe to consult *before* touching any DB-backed dependency.
*/
@Singleton
class EncryptedCacheGuard @Inject constructor(
private val settingsRepository: SettingsRepository,
private val session: PassphraseSession,
) {
/**
* True when the encrypted cache is currently locked, i.e. opening the database would suspend
* waiting for the user to authenticate. Background work should defer (e.g. `Result.retry()`).
*/
suspend fun isCacheLocked(): Boolean {
val settings = settingsRepository.settings.first()
return settings.appLock && settings.encryptCache && !session.isUnlocked()
}
}
@@ -6,11 +6,13 @@ import android.util.Log
import androidx.hilt.work.HiltWorker
import androidx.work.CoroutineWorker
import androidx.work.WorkerParameters
import dagger.Lazy
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import org.libremail.data.local.dao.AccountDao
import org.libremail.data.local.dao.OutboxDao
import org.libremail.data.local.toDomain
import org.libremail.data.security.EncryptedCacheGuard
import org.libremail.domain.model.Account
import org.libremail.domain.model.AuthType
import org.libremail.domain.model.OutgoingMessage
@@ -25,11 +27,15 @@ import kotlin.coroutines.cancellation.CancellationException
class SendWorker @AssistedInject constructor(
@Assisted appContext: Context,
@Assisted workerParams: WorkerParameters,
private val outboxDao: OutboxDao,
private val accountDao: AccountDao,
// Lazy: OutboxDao/AccountDao/MailConnectionFactory all resolve the Room DB (the last via
// CredentialStore), which blocks while the encrypted cache is locked. Resolve them only after the
// cache-lock check, so a run while locked fails fast instead of parking a WorkManager thread.
private val outboxDao: Lazy<OutboxDao>,
private val accountDao: Lazy<AccountDao>,
private val smtpSender: SmtpSender,
private val graphSender: GraphSender,
private val connectionFactory: MailConnectionFactory,
private val connectionFactory: Lazy<MailConnectionFactory>,
private val cacheGuard: EncryptedCacheGuard,
) : CoroutineWorker(appContext, workerParams) {
private companion object {
@@ -37,6 +43,10 @@ class SendWorker @AssistedInject constructor(
}
override suspend fun doWork(): Result {
if (cacheGuard.isCacheLocked()) return Result.retry()
val outboxDao = this.outboxDao.get()
val accountDao = this.accountDao.get()
val connectionFactory = this.connectionFactory.get()
val pending = outboxDao.getAll()
if (pending.isEmpty()) return Result.success()
@@ -61,7 +71,7 @@ class SendWorker @AssistedInject constructor(
)
val files = orderedAttachments(attachmentDir)
if (account.authType == AuthType.OAUTH_OUTLOOK) {
sendOutlook(account, message, files)
sendOutlook(connectionFactory, account, message, files)
} else {
smtpSender.send(
connectionFactory.smtpParamsFor(account),
@@ -100,7 +110,12 @@ class SendWorker @AssistedInject constructor(
* (a rejection, a pre-send/transport error, or a token failure); never fall back when the Graph
* request may already have been accepted, or the message would be sent twice.
*/
private suspend fun sendOutlook(account: Account, message: OutgoingMessage, files: List<File>) {
private suspend fun sendOutlook(
connectionFactory: MailConnectionFactory,
account: Account,
message: OutgoingMessage,
files: List<File>,
) {
try {
val token = connectionFactory.graphTokenFor(account)
graphSender.send(token, message, files)
@@ -5,18 +5,28 @@ import android.content.Context
import androidx.hilt.work.HiltWorker
import androidx.work.CoroutineWorker
import androidx.work.WorkerParameters
import dagger.Lazy
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import org.libremail.data.security.EncryptedCacheGuard
@HiltWorker
class SyncWorker @AssistedInject constructor(
@Assisted appContext: Context,
@Assisted workerParams: WorkerParameters,
private val mailSyncer: MailSyncer,
// Lazy: constructing MailSyncer builds the Room DB, which blocks while the encrypted cache is
// locked. Resolve it only after confirming the cache is unlocked, so a locked run fails fast.
private val mailSyncer: Lazy<MailSyncer>,
private val cacheGuard: EncryptedCacheGuard,
) : CoroutineWorker(appContext, workerParams) {
override suspend fun doWork(): Result = mailSyncer.syncAll().fold(
onSuccess = { Result.success() },
onFailure = { Result.retry() },
)
override suspend fun doWork(): Result {
// Can't open the encrypted DB without the user present — retry later rather than parking a
// WorkManager thread (which also wedges the shared serial executor) on an unsatisfiable await.
if (cacheGuard.isCacheLocked()) return Result.retry()
return mailSyncer.get().syncAll().fold(
onSuccess = { Result.success() },
onFailure = { Result.retry() },
)
}
}
@@ -35,7 +35,6 @@ import org.libremail.data.local.dao.MessageDao
import org.libremail.data.local.dao.OutboxDao
import org.libremail.data.local.dao.SignatureDao
import org.libremail.data.security.DatabaseKeyStore
import org.libremail.data.security.PassphraseSession
import org.libremail.data.settings.SettingsRepository
import javax.inject.Singleton
@@ -48,7 +47,6 @@ object DatabaseModule {
fun provideDatabase(
@ApplicationContext context: Context,
keyStore: DatabaseKeyStore,
passphraseSession: PassphraseSession,
settingsRepository: SettingsRepository,
): LibreMailDatabase {
val builder = Room.databaseBuilder(context, LibreMailDatabase::class.java, DB_NAME)
@@ -73,37 +71,39 @@ object DatabaseModule {
// before the database is opened — so it never races an open connection; toggling the setting
// therefore takes effect on the next app start. The passphrase is sealed by the Keystore.
//
// When app-lock is ON the sealing key is auth-bound, so the passphrase can only be resolved
// AFTER the user authenticates at the lock screen. We read it from PassphraseSession, which
// blocks (this runs on a background DI thread, never the UI thread) until the unlock flow in
// AppLockViewModel unwraps it. This is what makes the encrypted cache "only readable after
// authentication". With app-lock OFF the master-sealed passphrase auto-unwraps as before.
// The passphrase source is resolved from which seal actually exists
// ([DatabaseKeyStore.resolvePassphrase]), NOT from the app-lock setting (a separate DataStore
// that can disagree). When app-lock is ON the sealing key is auth-bound, so resolvePassphrase
// waits on PassphraseSession until the user authenticates. This provider must therefore never
// be constructed on the main thread while the cache is locked — LibreMailApplication injects
// AccountRepository lazily and the sync/push workers fail fast when locked, and the gate
// composes no DB-backed screen until Unlocked.
val dbFile = context.getDatabasePath(DB_NAME)
// A screen-lock change (biometric re-enrollment / lock removal) can invalidate the auth-bound
// key so the encrypted cache is no longer decryptable. AppLockViewModel records that and
// restarts the app; we wipe the cache HERE — at cold start, before Room opens — so the file is
// never deleted from under an open connection. A re-sync then repopulates it. No corruption.
if (runBlocking { keyStore.consumeClearPending() }) {
// never deleted from under an open connection. Crash-safe order: wipe + reset the seals, and
// only THEN clear the flag, so a kill mid-wipe just repeats the idempotent wipe next start.
if (runBlocking { keyStore.isClearPending() }) {
DatabaseFiles.clear(context)
runBlocking { keyStore.resetSealedPassphrase() }
runBlocking {
keyStore.resetSealedPassphrase()
keyStore.clearClearPending()
}
}
val settings = runBlocking { settingsRepository.settings.first() }
val appLock = settings.appLock
if (settings.encryptCache) {
val passphrase = runBlocking {
if (appLock) passphraseSession.await() else keyStore.passphrase()
}
val passphrase = runBlocking { keyStore.resolvePassphrase(appLock) }
DatabaseEncryption.ensureEncrypted(dbFile, passphrase)
builder.openHelperFactory(
SupportOpenHelperFactory(passphrase.toByteArray(Charsets.US_ASCII), null, false),
)
} else if (DatabaseEncryption.isEncrypted(dbFile)) {
// Encryption was turned back off — decrypt so the default (unkeyed) open succeeds.
val passphrase = runBlocking {
if (appLock) passphraseSession.await() else keyStore.passphrase()
}
val passphrase = runBlocking { keyStore.resolvePassphrase(appLock) }
DatabaseEncryption.ensurePlaintext(dbFile, passphrase)
}
return builder.build()
@@ -11,6 +11,7 @@ import android.util.Log
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.core.app.ServiceCompat
import dagger.Lazy
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
@@ -26,6 +27,7 @@ import kotlinx.coroutines.withTimeoutOrNull
import org.libremail.R
import org.libremail.data.local.dao.AccountDao
import org.libremail.data.local.toDomain
import org.libremail.data.security.EncryptedCacheGuard
import org.libremail.data.sync.MailConnectionFactory
import org.libremail.data.sync.MailSyncer
import org.libremail.domain.model.Account
@@ -40,13 +42,18 @@ import javax.inject.Inject
@AndroidEntryPoint
class IdleService : Service() {
@Inject lateinit var accountDao: AccountDao
// Lazy: AccountDao / MailConnectionFactory (via CredentialStore) / MailSyncer all resolve the Room
// DB, which blocks while the encrypted cache is locked. Resolve them only after the cache-lock
// check in onStartCommand, so a start into a still-locked process defers instead of ANR-ing.
@Inject lateinit var accountDao: Lazy<AccountDao>
@Inject lateinit var connectionFactory: MailConnectionFactory
@Inject lateinit var connectionFactory: Lazy<MailConnectionFactory>
@Inject lateinit var imapClient: ImapClient
@Inject lateinit var mailSyncer: MailSyncer
@Inject lateinit var mailSyncer: Lazy<MailSyncer>
@Inject lateinit var cacheGuard: EncryptedCacheGuard
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private var watching = false
@@ -58,7 +65,16 @@ class IdleService : Service() {
startAsForeground()
if (!watching) {
watching = true
scope.launch { reconcileWatchers() }
scope.launch {
// Can't open the encrypted DB without the user present. Defer (stop) and let the app
// restart push after the next unlock, rather than block the service and ANR.
if (cacheGuard.isCacheLocked()) {
Log.i(TAG, "encrypted cache locked; deferring IDLE push until the app is unlocked")
stopSelf()
return@launch
}
reconcileWatchers()
}
}
return START_STICKY
}
@@ -70,7 +86,7 @@ class IdleService : Service() {
* accounts exist, so reaching zero here is just a transient state.
*/
private suspend fun reconcileWatchers() {
accountDao.observeAll().collect { entities ->
accountDao.get().observeAll().collect { entities ->
val accounts = entities.map { it.toDomain() }
val currentIds = accounts.mapTo(mutableSetOf()) { it.id }
(watchers.keys - currentIds).forEach { id -> watchers.remove(id)?.cancel() }
@@ -94,10 +110,10 @@ class IdleService : Service() {
var backoffMs = INITIAL_BACKOFF_MS
while (scope.isActive) {
try {
val params = connectionFactory.imapParamsFor(account)
val params = connectionFactory.get().imapParamsFor(account)
withTimeoutOrNull(IDLE_RENEWAL_MS) {
// Sync just this account on its own push — not every account.
imapClient.idle(params) { mailSyncer.syncAccount(account.id) }
imapClient.idle(params) { mailSyncer.get().syncAccount(account.id) }
}
backoffMs = INITIAL_BACKOFF_MS
} catch (e: CancellationException) {
@@ -2,6 +2,7 @@
package org.libremail.ui.lock
import androidx.biometric.BiometricPrompt
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
@@ -9,7 +10,11 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.core.content.ContextCompat
@@ -64,20 +69,54 @@ fun AppLockGateHost(viewModel: AppLockViewModel = hiltViewModel(), content: @Com
)
}
when (val state = uiState) {
AppLockUiState.Checking ->
Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) {}
// Once unlocked, keep [content] in the composition across later re-locks so its state (navigation
// position, in-progress compose drafts, scroll) survives — the lock screen is drawn OVER it rather
// than replacing it. Content is never composed before the first unlock, so no DB-backed screen
// opens while the cache is still locked; a fresh process starts locked, so [remember] (not
// rememberSaveable) is intentional — content stays uncomposed until this session authenticates.
var hasEverUnlocked by remember { mutableStateOf(uiState is AppLockUiState.Unlocked) }
LaunchedEffect(uiState) { if (uiState is AppLockUiState.Unlocked) hasEverUnlocked = true }
AppLockUiState.Unlocked -> content()
Box(modifier = Modifier.fillMaxSize()) {
if (hasEverUnlocked) content()
is AppLockUiState.Locked -> {
LockScreen(error = state.error, onUnlock = authenticate)
// Auto-present the prompt the first time the lock screen appears; the button covers retries.
LaunchedEffect(Unit) { authenticate() }
when (val state = uiState) {
AppLockUiState.Unlocked -> Unit
AppLockUiState.Checking -> LockCover()
is AppLockUiState.Locked -> {
LockCover { LockScreen(error = state.error, onUnlock = authenticate) }
// Auto-present the prompt when the lock screen (re)appears from an unlocked/checking
// state; the button covers manual retries. Keyed to entering the Locked branch, so a
// retry (which stays in this branch) shows its error without re-triggering the prompt.
LaunchedEffect(Unit) { authenticate() }
}
}
}
}
/**
* Opaque, input-blocking overlay drawn on top of [content] while the app is locked or still
* resolving, so nothing underneath is visible or interactable. Screenshot / recents protection is the
* window's FLAG_SECURE (set by MainActivity while app-lock is on), which the composition can't do.
*/
@Composable
private fun LockCover(content: @Composable () -> Unit = {}) {
Surface(
modifier = Modifier
.fillMaxSize()
.pointerInput(Unit) {
awaitPointerEventScope {
while (true) {
awaitPointerEvent().changes.forEach { it.consume() }
}
}
},
color = MaterialTheme.colorScheme.background,
) { content() }
}
/**
* Presents a `BiometricPrompt` accepting a strong biometric OR the device credential. No negative
* button is set because that is disallowed when [DEVICE_CREDENTIAL][AppLockManager.AUTHENTICATORS] is
@@ -11,6 +11,7 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
@@ -18,6 +19,7 @@ import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.libremail.R
import org.libremail.data.security.AppLockGate
import org.libremail.data.security.AppLockManager
import org.libremail.data.security.DatabaseKeyCipher
@@ -38,8 +40,12 @@ sealed interface AppLockUiState {
/** App-lock is off or the user has authenticated: show the app. */
data object Unlocked : AppLockUiState
/** Show the lock screen; [error] is a human-readable reason the previous attempt failed. */
data class Locked(val error: String? = null) : AppLockUiState
/**
* Show the lock screen; [error] is a human-readable reason the previous attempt failed. [nonce]
* makes each lock emission distinct so a retry (same or null error text) still updates the UI
* rather than being swallowed by StateFlow's equality conflation.
*/
data class Locked(val error: String? = null, val nonce: Int = 0) : AppLockUiState
}
/**
@@ -74,13 +80,41 @@ class AppLockViewModel @Inject constructor(
private val _uiState = MutableStateFlow<AppLockUiState>(AppLockUiState.Checking)
val uiState: StateFlow<AppLockUiState> = _uiState.asStateFlow()
// Cached so onBackground / onForeground can cover the content synchronously (before the async
// settings read) whenever app-lock is on — so no stale mailbox frame renders on resume.
@Volatile private var appLockEnabledCached = false
// Bumped on every lock emission so two consecutive locks with identical error text still differ
// (StateFlow conflates equal values), guaranteeing the lock screen updates — e.g. on a retry.
private var lockSeq = 0
init {
viewModelScope.launch {
settingsRepository.settings.collect { appLockEnabledCached = it.appLock }
}
}
private fun emitLocked(error: String? = null) {
_uiState.value = AppLockUiState.Locked(error, ++lockSeq)
}
/** Recompute the lock state when the app comes to the foreground (lifecycle ON_START). */
fun onForeground() {
// Capture the foreground timestamp synchronously (before any suspension) so a concurrent
// onBackground can't corrupt the grace calculation for this pass (see AppLockGate.onForeground).
val foregroundAt = now()
viewModelScope.launch {
val settings = settingsRepository.settings.first()
if (!settings.appLock) {
_uiState.value = AppLockUiState.Unlocked
return@launch
}
// App-lock is on: cover any showing content while we resolve, so no stale mailbox frame
// renders before the (async) decision lands.
if (_uiState.value == AppLockUiState.Unlocked) _uiState.value = AppLockUiState.Checking
val action = withContext(Dispatchers.Default) {
KeyInvalidationPolicy.decide(
appLockEnabled = settings.appLock,
appLockEnabled = true,
encryptCacheEnabled = settings.encryptCache,
deviceSecure = appLockManager.isDeviceSecure(),
keyInvalidated = databaseKeyCipher.isInvalidated(),
@@ -94,17 +128,13 @@ class AppLockViewModel @Inject constructor(
_uiState.value = AppLockUiState.Unlocked
}
LockAction.CLEAR_AND_DISABLE -> {
settingsRepository.setAppLock(false)
clearCacheAndRestart()
}
LockAction.CLEAR_AND_DISABLE -> clearCacheAndRestart(disableAppLock = true)
LockAction.CLEAR_AND_REQUIRE_AUTH -> clearCacheAndRestart()
LockAction.CLEAR_AND_REQUIRE_AUTH -> clearCacheAndRestart(disableAppLock = false)
LockAction.REQUIRE_AUTH -> {
val state = gate.onForeground(now(), appLockEnabled = true)
_uiState.value =
if (state == LockState.UNLOCKED) AppLockUiState.Unlocked else AppLockUiState.Locked()
val state = gate.onForeground(foregroundAt, appLockEnabled = true)
if (state == LockState.UNLOCKED) _uiState.value = AppLockUiState.Unlocked else emitLocked()
}
}
}
@@ -113,6 +143,11 @@ class AppLockViewModel @Inject constructor(
/** Record the app being backgrounded so the inactivity grace period can be evaluated on return. */
fun onBackground() {
gate.onBackground(now())
// Cover the content the moment we background so nothing sensitive is in the last-rendered frame
// (recents snapshot) or briefly visible on the next resume before the re-lock decision lands.
if (appLockEnabledCached && _uiState.value == AppLockUiState.Unlocked) {
_uiState.value = AppLockUiState.Checking
}
}
/** Called by the host after a successful `BiometricPrompt`. */
@@ -128,12 +163,12 @@ class AppLockViewModel @Inject constructor(
// The passphrase is permanently unrecoverable (key invalidated or deleted by a
// screen-lock change). Wipe the cache safely at the next cold start and re-sync.
Log.w(TAG, "encrypted cache passphrase unrecoverable; clearing cache")
clearCacheAndRestart()
clearCacheAndRestart(disableAppLock = false)
}
UnlockResult.RETRY -> {
gate.lock()
_uiState.value = AppLockUiState.Locked()
emitLocked(context.getString(R.string.app_lock_unlock_failed))
}
}
}
@@ -142,7 +177,7 @@ class AppLockViewModel @Inject constructor(
/** Called by the host when the prompt is cancelled or errors. */
fun onAuthError(message: String?) {
gate.lock()
_uiState.value = AppLockUiState.Locked(message)
emitLocked(message)
}
/** Outcome of unwrapping/arming the auth-bound passphrase after a successful device auth. */
@@ -190,12 +225,24 @@ class AppLockViewModel @Inject constructor(
} catch (e: UserNotAuthenticatedException) {
Log.w(TAG, "auth window elapsed before unwrap; will retry", e)
UnlockResult.RETRY
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
// Any other failure (corrupt sealed blob, OEM keymaster error, etc.) must NOT crash the
// process right after a successful auth. Re-lock and let the user retry rather than wiping
// the cache on an ambiguous error (a genuinely lost key still surfaces as UNRECOVERABLE
// via hasKey()/KeyPermanentlyInvalidatedException above).
Log.w(TAG, "unexpected failure unwrapping auth-sealed passphrase; will retry", e)
UnlockResult.RETRY
}
}
private suspend fun clearCacheAndRestart() {
private suspend fun clearCacheAndRestart(disableAppLock: Boolean) {
withContext(Dispatchers.Default) {
// Record the wipe intent BEFORE flipping app-lock off, so a crash between the two writes
// leaves the wipe still pending (recoverable) rather than a disabled gate over a stale key.
databaseKeyStore.setClearPending()
if (disableAppLock) settingsRepository.setAppLock(false)
syncScheduler.syncNow() // persisted by WorkManager; survives the restart
}
restartProcess()
@@ -9,7 +9,6 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
@@ -84,10 +83,11 @@ class SettingsViewModel @Inject constructor(
}
settingsRepository.setAppLock(true)
} else {
if (settingsRepository.settings.first().encryptCache) {
// Move the passphrase back under the master key BEFORE dropping the gate, or the next
// launch derives a fresh (wrong) key and can't open the encrypted cache. If it fails,
// keep app-lock on rather than risk that mismatch.
// Reseal under the master key whenever an auth seal actually exists — gate on the seal, not
// the encryptCache setting (a separate store that can already be off while the on-disk DB is
// still auth-sealed). Do it BEFORE dropping the gate, or the next launch can't open the
// cache. If it fails, keep app-lock on rather than strand the passphrase.
if (databaseKeyStore.hasAuthSealedPassphrase()) {
if (runCatching { databaseKeyStore.sealWithMaster() }.isFailure) {
_appLockMessage.value = R.string.app_lock_disable_failed
return@update
+1
View File
@@ -246,6 +246,7 @@
<string name="app_lock_prompt_subtitle">Confirm your screen lock to decrypt your mail.</string>
<string name="app_lock_needs_device_lock">Set up a device screen lock in Android settings before enabling this.</string>
<string name="app_lock_disable_failed">Couldn\'t turn off the screen lock right now. Please try again.</string>
<string name="app_lock_unlock_failed">Couldn\'t unlock. Please try again.</string>
<string name="settings_adv_battery">Background battery usage</string>
<string name="settings_adv_battery_unrestricted">Unrestricted — instant background mail is allowed.</string>
@@ -78,4 +78,26 @@ class AppLockGateTest {
gate.lock()
assertEquals(LockState.UNLOCKED, gate.onForeground(now = 100, appLockEnabled = false))
}
@Test
fun `a background recorded after a foreground pass began does not unlock on that stale pass`() {
// Regression (lock-bypass race): a stale foreground pass whose timestamp was captured before a
// later background must NOT treat that background as a within-grace return and stay unlocked.
val gate = AppLockGate(grace)
gate.onForeground(now = 0, appLockEnabled = true)
gate.onAuthenticated() // UNLOCKED
val staleForegroundAt = 1_000L
gate.onBackground(now = 2_000L) // background happens AFTER the stale pass began
assertEquals(LockState.LOCKED, gate.onForeground(now = staleForegroundAt, appLockEnabled = true))
}
@Test
fun `the genuine return after a stale pass still requires authentication`() {
val gate = AppLockGate(grace)
gate.onForeground(now = 0, appLockEnabled = true)
gate.onAuthenticated()
gate.onBackground(now = 2_000L)
gate.onForeground(now = 1_000L, appLockEnabled = true) // stale -> LOCKED, marker retained
assertEquals(LockState.LOCKED, gate.onForeground(now = 3_000L, appLockEnabled = true))
}
}
@@ -51,4 +51,30 @@ class KeyInvalidationPolicyTest {
// Both true: the device being insecure dominates (can't authenticate at all).
assertEquals(LockAction.CLEAR_AND_DISABLE, decide(secure = false, invalidated = true, encrypt = true))
}
@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),
)
}
}
}
// 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))
}
}