Merge pull request #45 from JMR-dev/feat-screen-unlock
[needs careful review] feat(security): screen-lock app gate + auth-bound cache decrypt (#22)
This commit was merged in pull request #45.
This commit is contained in:
@@ -172,6 +172,7 @@ dependencies {
|
||||
implementation(libs.androidx.activity.compose)
|
||||
implementation(libs.androidx.navigation.compose)
|
||||
implementation(libs.androidx.webkit)
|
||||
implementation(libs.androidx.biometric)
|
||||
implementation(libs.kotlinx.coroutines.android)
|
||||
|
||||
// Email transport (IMAP/SMTP) + OAuth
|
||||
|
||||
@@ -13,6 +13,12 @@ import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.libremail.R
|
||||
import org.libremail.data.security.AppLockAvailability
|
||||
import org.libremail.data.security.AppLockManager
|
||||
import org.libremail.data.security.DatabaseKeyCipher
|
||||
import org.libremail.data.security.DatabaseKeyStore
|
||||
import org.libremail.data.security.KeystoreCrypto
|
||||
import org.libremail.data.security.PassphraseSession
|
||||
import org.libremail.data.settings.FetchPolicy
|
||||
import org.libremail.data.settings.SettingsRepository
|
||||
import org.libremail.data.sync.SyncScheduler
|
||||
@@ -37,9 +43,16 @@ class SettingsScreenTest {
|
||||
val context = InstrumentationRegistry.getInstrumentation().targetContext.applicationContext
|
||||
val settingsRepository = SettingsRepository(context)
|
||||
runBlocking { settingsRepository.setFetchPolicy(FetchPolicy.ALWAYS) } // known starting state
|
||||
val appLockManager = object : AppLockManager {
|
||||
override fun isDeviceSecure() = false
|
||||
override fun availability() = AppLockAvailability.NONE_ENROLLED
|
||||
}
|
||||
val keyStore = DatabaseKeyStore(context, KeystoreCrypto(), DatabaseKeyCipher(), PassphraseSession())
|
||||
val viewModel = SettingsViewModel(
|
||||
FakeAccountRepository(),
|
||||
settingsRepository,
|
||||
appLockManager,
|
||||
keyStore,
|
||||
BatteryOptimizationManager(context),
|
||||
SyncScheduler(context),
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -74,7 +78,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,7 +6,7 @@ import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
import android.view.WindowManager
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
@@ -17,18 +17,27 @@ import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
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.notifications.NotificationIntents
|
||||
import org.libremail.ui.LibreMailApp
|
||||
import org.libremail.ui.compose.ComposePrefill
|
||||
import org.libremail.ui.compose.IntentComposeParser
|
||||
import org.libremail.ui.lock.AppLockGateHost
|
||||
import org.libremail.ui.theme.LibreMailTheme
|
||||
import javax.inject.Inject
|
||||
|
||||
// FragmentActivity (not ComponentActivity) because BiometricPrompt requires one for the app-lock
|
||||
// flow. FragmentActivity extends androidx.activity.ComponentActivity, so setContent / enableEdgeToEdge
|
||||
// and Hilt injection keep working unchanged.
|
||||
@AndroidEntryPoint
|
||||
class MainActivity : ComponentActivity() {
|
||||
class MainActivity : FragmentActivity() {
|
||||
|
||||
@Inject
|
||||
lateinit var settingsRepository: SettingsRepository
|
||||
@@ -54,6 +63,18 @@ class MainActivity : ComponentActivity() {
|
||||
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 /
|
||||
// reader destination itself, so re-parsing the (unchanged) intent would open a duplicate.
|
||||
if (savedInstanceState == null) {
|
||||
@@ -64,12 +85,16 @@ class MainActivity : ComponentActivity() {
|
||||
val dynamicColor by settingsRepository.dynamicColor.collectAsStateWithLifecycle(initialValue = true)
|
||||
LibreMailTheme(dynamicColor = dynamicColor) {
|
||||
NotificationPermissionEffect()
|
||||
LibreMailApp(
|
||||
pendingCompose = pendingCompose.value,
|
||||
onComposeHandled = { pendingCompose.value = null },
|
||||
pendingOpenMessageId = pendingOpenMessageId.value,
|
||||
onOpenMessageHandled = { pendingOpenMessageId.value = null },
|
||||
)
|
||||
// Gate the whole app behind the screen-lock when app-lock is enabled. When it is off
|
||||
// the gate resolves straight to the content, so this is a no-op for most users.
|
||||
AppLockGateHost {
|
||||
LibreMailApp(
|
||||
pendingCompose = pendingCompose.value,
|
||||
onComposeHandled = { pendingCompose.value = null },
|
||||
pendingOpenMessageId = pendingOpenMessageId.value,
|
||||
onOpenMessageHandled = { pendingOpenMessageId.value = null },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
package org.libremail.data.local
|
||||
|
||||
import android.content.Context
|
||||
import java.io.File
|
||||
|
||||
/** Central name and wipe helper for the Room cache database file (and its SQLite sidecars). */
|
||||
object DatabaseFiles {
|
||||
|
||||
const val NAME = "libremail.db"
|
||||
|
||||
/**
|
||||
* Delete the database and any WAL/SHM/journal sidecars. Call only when no connection is open —
|
||||
* used by the "clear + re-sync" path when the encryption key is invalidated and the encrypted
|
||||
* database can no longer be decrypted.
|
||||
*/
|
||||
fun clear(context: Context) {
|
||||
val db = context.getDatabasePath(NAME)
|
||||
val dir = db.parentFile ?: return
|
||||
listOf("", "-wal", "-shm", "-journal").forEach { suffix ->
|
||||
File(dir, db.name + suffix).delete()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
package org.libremail.data.security
|
||||
|
||||
/** Whether the app UI is currently gated behind the screen-lock. */
|
||||
enum class LockState { LOCKED, UNLOCKED }
|
||||
|
||||
/**
|
||||
* Pure state machine for the app-lock UI gate. Decides when the lock screen must be shown from
|
||||
* foreground/background transitions plus an inactivity grace period. It has no Android or crypto
|
||||
* dependencies, so the (security-critical) gating logic is exercised entirely by JVM unit tests.
|
||||
*
|
||||
* Rules:
|
||||
* - App-lock disabled: always [LockState.UNLOCKED].
|
||||
* - Cold start with app-lock on: starts [LockState.LOCKED] until [onAuthenticated].
|
||||
* - Returning to the foreground within [graceMillis] of backgrounding stays unlocked; after the
|
||||
* grace period it re-locks. Foregrounding without a preceding background (e.g. a configuration
|
||||
* change / rotation) does not re-lock an already-unlocked session.
|
||||
*/
|
||||
class AppLockGate(private val graceMillis: Long = DEFAULT_GRACE_MILLIS) {
|
||||
|
||||
var state: LockState = LockState.LOCKED
|
||||
private set
|
||||
|
||||
private var backgroundedAt: Long? = null
|
||||
|
||||
/**
|
||||
* 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
|
||||
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
|
||||
}
|
||||
// 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
|
||||
}
|
||||
|
||||
/** Record the time the app was backgrounded, to evaluate the grace period on return. */
|
||||
fun onBackground(now: Long) {
|
||||
backgroundedAt = now
|
||||
}
|
||||
|
||||
/** Mark the session unlocked after a successful authentication. */
|
||||
fun onAuthenticated() {
|
||||
state = LockState.UNLOCKED
|
||||
backgroundedAt = null
|
||||
}
|
||||
|
||||
/** Force the gate back to locked (e.g. after cache invalidation). */
|
||||
fun lock() {
|
||||
state = LockState.LOCKED
|
||||
}
|
||||
|
||||
companion object {
|
||||
/** Time the user can leave the app before re-authentication is required. */
|
||||
const val DEFAULT_GRACE_MILLIS: Long = 30_000
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
package org.libremail.data.security
|
||||
|
||||
import android.app.KeyguardManager
|
||||
import android.content.Context
|
||||
import androidx.biometric.BiometricManager
|
||||
import androidx.biometric.BiometricManager.Authenticators.BIOMETRIC_STRONG
|
||||
import androidx.biometric.BiometricManager.Authenticators.DEVICE_CREDENTIAL
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/** Whether a usable device authenticator (strong biometric or device credential) is available. */
|
||||
enum class AppLockAvailability {
|
||||
/** A biometric or device credential can be presented right now. */
|
||||
AVAILABLE,
|
||||
|
||||
/** Hardware exists but nothing (biometric or PIN/pattern/password) is enrolled. */
|
||||
NONE_ENROLLED,
|
||||
|
||||
/** No suitable authentication hardware, or it is temporarily unavailable. */
|
||||
NO_HARDWARE,
|
||||
|
||||
/** Availability could not be determined (unknown status / security update required). */
|
||||
UNAVAILABLE,
|
||||
}
|
||||
|
||||
/**
|
||||
* Queries device-lock and biometric availability for the app-lock feature. Real behavior depends on
|
||||
* the device keyguard and BiometricManager, so it is validated on-device, not in JVM tests.
|
||||
*/
|
||||
interface AppLockManager {
|
||||
|
||||
/** True when the device has a secure lock screen (PIN, pattern, password, or biometric). */
|
||||
fun isDeviceSecure(): Boolean
|
||||
|
||||
/** Whether a biometric or device-credential prompt can currently be shown. */
|
||||
fun availability(): AppLockAvailability
|
||||
|
||||
companion object {
|
||||
/** Accept a strong biometric OR the device credential (PIN/pattern/password) as fallback. */
|
||||
const val AUTHENTICATORS: Int = BIOMETRIC_STRONG or DEVICE_CREDENTIAL
|
||||
}
|
||||
}
|
||||
|
||||
@Singleton
|
||||
class AndroidAppLockManager @Inject constructor(@ApplicationContext private val context: Context) : AppLockManager {
|
||||
|
||||
private val keyguardManager: KeyguardManager?
|
||||
get() = context.getSystemService(Context.KEYGUARD_SERVICE) as? KeyguardManager
|
||||
|
||||
override fun isDeviceSecure(): Boolean = keyguardManager?.isDeviceSecure == true
|
||||
|
||||
override fun availability(): AppLockAvailability =
|
||||
when (BiometricManager.from(context).canAuthenticate(AppLockManager.AUTHENTICATORS)) {
|
||||
BiometricManager.BIOMETRIC_SUCCESS -> AppLockAvailability.AVAILABLE
|
||||
BiometricManager.BIOMETRIC_ERROR_NONE_ENROLLED -> AppLockAvailability.NONE_ENROLLED
|
||||
BiometricManager.BIOMETRIC_ERROR_NO_HARDWARE,
|
||||
BiometricManager.BIOMETRIC_ERROR_HW_UNAVAILABLE,
|
||||
-> AppLockAvailability.NO_HARDWARE
|
||||
else -> AppLockAvailability.UNAVAILABLE
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
package org.libremail.data.security
|
||||
|
||||
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
|
||||
import javax.crypto.Cipher
|
||||
import javax.crypto.KeyGenerator
|
||||
import javax.crypto.SecretKey
|
||||
import javax.crypto.spec.GCMParameterSpec
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* AES-256-GCM key in the Android Keystore that seals the SQLCipher passphrase and REQUIRES user
|
||||
* authentication (a strong biometric or the device credential) to use — the auth-bound counterpart
|
||||
* to [KeystoreCrypto], which stays non-auth-bound so background credential access keeps working.
|
||||
*
|
||||
* The key is time-bound: a successful `BiometricPrompt` / device-credential unlock authorizes it for
|
||||
* [AUTH_VALIDITY_SECONDS], long enough to unwrap the passphrase into [PassphraseSession]. Because the
|
||||
* validity window is > 0 no `CryptoObject` is required, which keeps the flow working on API 29 (where
|
||||
* a `CryptoObject` cannot be combined with `DEVICE_CREDENTIAL`).
|
||||
*
|
||||
* [setInvalidatedByBiometricEnrollment] means enrolling a new biometric — and removing the device
|
||||
* lock — permanently invalidates the key; [decrypt] then throws [KeyPermanentlyInvalidatedException],
|
||||
* which the caller treats as "cache unrecoverable -> clear + re-sync".
|
||||
*
|
||||
* DEVICE-ONLY: auth-bound Keystore keys and BiometricPrompt cannot be exercised in JVM unit tests;
|
||||
* this class is covered by on-device instrumentation / manual validation only.
|
||||
*/
|
||||
@Singleton
|
||||
class DatabaseKeyCipher @Inject constructor() {
|
||||
|
||||
private val keyLock = Any()
|
||||
|
||||
/**
|
||||
* Returns Base64(iv || ciphertext). Requires a valid auth window (call right after unlock).
|
||||
* Self-heals a stale, permanently-invalidated key by replacing it and retrying once, so sealing a
|
||||
* fresh passphrase after a re-enrollment doesn't fail.
|
||||
*/
|
||||
fun encrypt(plaintext: String): String = try {
|
||||
doEncrypt(getOrCreateKey(), plaintext)
|
||||
} catch (e: KeyPermanentlyInvalidatedException) {
|
||||
Log.d(TAG, "replacing invalidated auth-bound key before sealing", e)
|
||||
deleteKey()
|
||||
doEncrypt(getOrCreateKey(), plaintext)
|
||||
}
|
||||
|
||||
private fun doEncrypt(key: SecretKey, plaintext: String): String {
|
||||
val cipher = Cipher.getInstance(TRANSFORMATION)
|
||||
cipher.init(Cipher.ENCRYPT_MODE, key)
|
||||
val iv = cipher.iv
|
||||
val ciphertext = cipher.doFinal(plaintext.toByteArray(Charsets.UTF_8))
|
||||
return Base64.encodeToString(iv + ciphertext, Base64.NO_WRAP)
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypts a blob produced by [encrypt]. Requires a valid auth window. Throws
|
||||
* [KeyPermanentlyInvalidatedException] if the key was invalidated by re-enrollment / lock removal.
|
||||
*/
|
||||
fun decrypt(encoded: String): String {
|
||||
val key = existingKey() ?: error("auth-bound database key is missing")
|
||||
val bytes = Base64.decode(encoded, Base64.NO_WRAP)
|
||||
val iv = bytes.copyOfRange(0, IV_LENGTH)
|
||||
val ciphertext = bytes.copyOfRange(IV_LENGTH, bytes.size)
|
||||
val cipher = Cipher.getInstance(TRANSFORMATION)
|
||||
cipher.init(Cipher.DECRYPT_MODE, key, GCMParameterSpec(TAG_BITS, iv))
|
||||
return String(cipher.doFinal(ciphertext), Charsets.UTF_8)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
return try {
|
||||
Cipher.getInstance(TRANSFORMATION).init(Cipher.ENCRYPT_MODE, key)
|
||||
false
|
||||
} 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
|
||||
}
|
||||
}
|
||||
|
||||
fun hasKey(): Boolean = existingKey() != null
|
||||
|
||||
/** Deletes the auth-bound key so a fresh one is generated on the next [encrypt]. */
|
||||
fun deleteKey(): Unit = synchronized(keyLock) {
|
||||
KeyStore.getInstance(ANDROID_KEYSTORE).apply { load(null) }.deleteEntry(KEY_ALIAS)
|
||||
}
|
||||
|
||||
private fun existingKey(): SecretKey? = synchronized(keyLock) {
|
||||
val keyStore = KeyStore.getInstance(ANDROID_KEYSTORE).apply { load(null) }
|
||||
(keyStore.getEntry(KEY_ALIAS, null) as? KeyStore.SecretKeyEntry)?.secretKey
|
||||
}
|
||||
|
||||
private fun getOrCreateKey(): SecretKey = synchronized(keyLock) {
|
||||
existingKey()?.let { return it }
|
||||
val generator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, ANDROID_KEYSTORE)
|
||||
generator.init(buildSpec())
|
||||
generator.generateKey()
|
||||
}
|
||||
|
||||
private fun buildSpec(): KeyGenParameterSpec {
|
||||
val builder = KeyGenParameterSpec.Builder(
|
||||
KEY_ALIAS,
|
||||
KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT,
|
||||
)
|
||||
.setBlockModes(KeyProperties.BLOCK_MODE_GCM)
|
||||
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
|
||||
.setKeySize(AES_KEY_SIZE_BITS)
|
||||
.setUserAuthenticationRequired(true)
|
||||
.setInvalidatedByBiometricEnrollment(true)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
builder.setUserAuthenticationParameters(
|
||||
AUTH_VALIDITY_SECONDS,
|
||||
KeyProperties.AUTH_BIOMETRIC_STRONG or KeyProperties.AUTH_DEVICE_CREDENTIAL,
|
||||
)
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
builder.setUserAuthenticationValidityDurationSeconds(AUTH_VALIDITY_SECONDS)
|
||||
}
|
||||
return builder.build()
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val ANDROID_KEYSTORE = "AndroidKeyStore"
|
||||
const val KEY_ALIAS = "libremail.dbkey.auth"
|
||||
const val TRANSFORMATION = "AES/GCM/NoPadding"
|
||||
const val IV_LENGTH = 12
|
||||
const val TAG_BITS = 128
|
||||
const val AES_KEY_SIZE_BITS = 256
|
||||
const val AUTH_VALIDITY_SECONDS = 15
|
||||
const val TAG = "LibreMailDbKeyAuth"
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ package org.libremail.data.security
|
||||
import android.content.Context
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.core.booleanPreferencesKey
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import androidx.datastore.preferences.preferencesDataStore
|
||||
@@ -17,11 +18,20 @@ 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 the non-exportable Android Keystore
|
||||
* key via [KeystoreCrypto] — so the key that protects the cache is itself protected at rest. The
|
||||
* passphrase is returned as a 64-character hex string and used directly as the SQLCipher passphrase.
|
||||
* generated once and persisted only as ciphertext — sealed by a non-exportable Android Keystore key
|
||||
* — so the key that protects the cache is itself protected at rest. The passphrase is returned as a
|
||||
* 64-character hex string and used directly as the SQLCipher passphrase.
|
||||
*
|
||||
* The passphrase can be sealed two ways, but never both at once:
|
||||
* - [SEALED_MASTER]: by the non-auth [KeystoreCrypto] master key — auto-unwraps, used when app-lock
|
||||
* is OFF (unchanged legacy behavior).
|
||||
* - [SEALED_AUTH]: by the auth-bound [DatabaseKeyCipher] — only unwrappable after the user passes a
|
||||
* `BiometricPrompt`, used when app-lock is ON. The unwrapped value lives in [PassphraseSession].
|
||||
*
|
||||
* Stored in its own DataStore (not the Room database it protects) to avoid a chicken-and-egg cycle.
|
||||
*/
|
||||
@@ -29,32 +39,158 @@ private val Context.dbKeyDataStore: DataStore<Preferences> by preferencesDataSto
|
||||
class DatabaseKeyStore @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
private val crypto: KeystoreCrypto,
|
||||
private val authCipher: DatabaseKeyCipher,
|
||||
private val session: PassphraseSession,
|
||||
) {
|
||||
private val generationLock = Mutex()
|
||||
|
||||
/** Returns the cache passphrase, generating and sealing it on first use. */
|
||||
/**
|
||||
* 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. 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 {
|
||||
existing()?.let { return it }
|
||||
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).
|
||||
existing() ?: generateAndStore()
|
||||
masterSealed()?.let { return@withLock it }
|
||||
check(read(SEALED_AUTH) == null) {
|
||||
"refusing to generate a master passphrase while an auth-sealed passphrase exists"
|
||||
}
|
||||
generateAndSealMaster()
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun existing(): String? = context.dbKeyDataStore.data.first()[SEALED_KEY]?.let {
|
||||
crypto.decrypt(it)
|
||||
/** True when a passphrase sealed by the auth-bound key is stored (app-lock is armed). */
|
||||
suspend fun hasAuthSealedPassphrase(): Boolean = read(SEALED_AUTH) != null
|
||||
|
||||
/**
|
||||
* Unwrap the auth-sealed passphrase into [PassphraseSession]. Call right after a successful
|
||||
* unlock (within the key's auth-validity window). No-op if nothing is auth-sealed yet. Throws
|
||||
* [android.security.keystore.KeyPermanentlyInvalidatedException] if the key was invalidated.
|
||||
*/
|
||||
suspend fun unlockWithAuth() {
|
||||
val sealed = read(SEALED_AUTH) ?: return
|
||||
session.unlock(authCipher.decrypt(sealed))
|
||||
}
|
||||
|
||||
private suspend fun generateAndStore(): String {
|
||||
val raw = ByteArray(KEY_BYTES).also { SecureRandom().nextBytes(it) }
|
||||
val hex = raw.joinToString("") { "%02x".format(it) }
|
||||
context.dbKeyDataStore.edit { it[SEALED_KEY] = crypto.encrypt(hex) }
|
||||
/**
|
||||
* Enable app-lock: (re)seal the passphrase with the auth-bound key and drop the master-sealed
|
||||
* copy so the cache key is no longer recoverable without authentication. Requires a valid auth
|
||||
* window (call right after a successful `BiometricPrompt`). Reuses the existing passphrase when
|
||||
* present so an already-encrypted cache stays readable; otherwise mints a fresh one. Unlocks the
|
||||
* session with the resulting value.
|
||||
*/
|
||||
suspend fun sealWithAuth(): Unit = generationLock.withLock {
|
||||
val plain = masterSealed() ?: session.current() ?: generateHex()
|
||||
val sealed = authCipher.encrypt(plain)
|
||||
context.dbKeyDataStore.edit {
|
||||
it[SEALED_AUTH] = sealed
|
||||
it.remove(SEALED_MASTER)
|
||||
}
|
||||
session.unlock(plain)
|
||||
}
|
||||
|
||||
/**
|
||||
* Disable app-lock: reseal the passphrase with the master key and drop the auth-sealed copy so
|
||||
* the cache opens without authentication again. Requires the passphrase to be available — the
|
||||
* session must be unlocked (i.e. the user authenticated earlier this session), which is always
|
||||
* true while the user is inside the (gated) settings screen.
|
||||
*/
|
||||
suspend fun sealWithMaster(): Unit = generationLock.withLock {
|
||||
val plain = session.current() ?: read(SEALED_AUTH)?.let { authCipher.decrypt(it) } ?: return@withLock
|
||||
context.dbKeyDataStore.edit {
|
||||
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()
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop every sealed copy of the passphrase and delete the auth-bound key. Used when the key is
|
||||
* invalidated (re-enrollment / lock removal) and the encrypted cache is being wiped: a fresh
|
||||
* passphrase is generated on the next [sealWithAuth] / [passphrase]. Callers MUST wipe the
|
||||
* encrypted database file in the same operation, otherwise it becomes permanently unreadable.
|
||||
*/
|
||||
suspend fun resetSealedPassphrase(): Unit = generationLock.withLock {
|
||||
context.dbKeyDataStore.edit {
|
||||
it.remove(SEALED_AUTH)
|
||||
it.remove(SEALED_MASTER)
|
||||
}
|
||||
authCipher.deleteKey()
|
||||
session.lock()
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist that the encrypted cache must be wiped on the next cold start. The actual file deletion
|
||||
* 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.
|
||||
*/
|
||||
suspend fun setClearPending() {
|
||||
context.dbKeyDataStore.edit { it[CLEAR_PENDING] = true }
|
||||
}
|
||||
|
||||
/**
|
||||
* 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) }
|
||||
|
||||
private suspend fun read(key: Preferences.Key<String>): String? = context.dbKeyDataStore.data.first()[key]
|
||||
|
||||
private suspend fun generateAndSealMaster(): String {
|
||||
val hex = generateHex()
|
||||
context.dbKeyDataStore.edit { it[SEALED_MASTER] = crypto.encrypt(hex) }
|
||||
return hex
|
||||
}
|
||||
|
||||
private fun generateHex(): String {
|
||||
val raw = ByteArray(KEY_BYTES).also { SecureRandom().nextBytes(it) }
|
||||
return raw.joinToString("") { "%02x".format(it) }
|
||||
}
|
||||
|
||||
private companion object {
|
||||
val SEALED_KEY = stringPreferencesKey("sealed_db_key")
|
||||
// Key name kept as the original "sealed_db_key" so existing installs' master-sealed
|
||||
// passphrase is read back unchanged after upgrade.
|
||||
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 KEY_BYTES = 32
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
package org.libremail.data.security
|
||||
|
||||
/** What the app-lock gate should do when a foreground pass reconciles settings with device state. */
|
||||
enum class LockAction {
|
||||
/** App-lock is off; show the app without gating. */
|
||||
PROCEED,
|
||||
|
||||
/** Prompt the user to authenticate, then unwrap (or first-time arm) the passphrase. */
|
||||
REQUIRE_AUTH,
|
||||
|
||||
/**
|
||||
* The auth-bound key was invalidated (new biometric enrollment) but the device is still secure
|
||||
* and an encrypted cache sealed by that key exists: the cache is unrecoverable, so wipe it and
|
||||
* re-sync, then require a fresh authentication which re-arms the key.
|
||||
*/
|
||||
CLEAR_AND_REQUIRE_AUTH,
|
||||
|
||||
/**
|
||||
* The device no longer has a secure lock and there is nothing encrypted to protect: silently
|
||||
* disable app-lock (there is no key to authenticate against) and proceed.
|
||||
*/
|
||||
DISABLE_APP_LOCK,
|
||||
|
||||
/**
|
||||
* The device no longer has a secure lock but an encrypted cache sealed by the (now invalidated)
|
||||
* auth-bound key exists: the cache is unrecoverable, so wipe it and re-sync, then disable
|
||||
* app-lock and proceed. The cache re-encrypts under a fresh, non-auth key or stays plaintext.
|
||||
*/
|
||||
CLEAR_AND_DISABLE,
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure decision table reconciling the app-lock / encrypted-cache settings with the current device
|
||||
* security state and Keystore key validity. Extracted from any Android or crypto dependency so the
|
||||
* (security-critical) branch logic — in particular the "clear + re-sync, never corrupt" handling of
|
||||
* lock removal and biometric re-enrollment — is fully unit-tested.
|
||||
*/
|
||||
object KeyInvalidationPolicy {
|
||||
|
||||
fun decide(
|
||||
appLockEnabled: Boolean,
|
||||
encryptCacheEnabled: Boolean,
|
||||
deviceSecure: Boolean,
|
||||
keyInvalidated: Boolean,
|
||||
): LockAction = when {
|
||||
!appLockEnabled -> LockAction.PROCEED
|
||||
!deviceSecure -> if (encryptCacheEnabled) LockAction.CLEAR_AND_DISABLE else LockAction.DISABLE_APP_LOCK
|
||||
keyInvalidated -> if (encryptCacheEnabled) LockAction.CLEAR_AND_REQUIRE_AUTH else LockAction.REQUIRE_AUTH
|
||||
else -> LockAction.REQUIRE_AUTH
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
package org.libremail.data.security
|
||||
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.filterNotNull
|
||||
import kotlinx.coroutines.flow.first
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* In-memory holder for the decrypted SQLCipher passphrase during an unlocked session.
|
||||
*
|
||||
* When app-lock is enabled the database passphrase is sealed by an auth-bound Keystore key
|
||||
* ([DatabaseKeyCipher]) and is only recoverable after the user passes a `BiometricPrompt`. The
|
||||
* unwrapped passphrase is kept here for the lifetime of the unlocked session — never persisted — and
|
||||
* cleared on lock, timeout, or when app-lock is disabled. `DatabaseModule` reads it through [await]
|
||||
* so the encrypted database is opened only after authentication.
|
||||
*
|
||||
* The value is an immutable [String] to stay consistent with the rest of the passphrase plumbing; it
|
||||
* cannot be zeroed in place, which is an accepted limitation of the existing design.
|
||||
*/
|
||||
@Singleton
|
||||
class PassphraseSession @Inject constructor() {
|
||||
|
||||
private val passphrase = MutableStateFlow<String?>(null)
|
||||
|
||||
/** Store the unwrapped passphrase after a successful unlock. */
|
||||
fun unlock(value: String) {
|
||||
passphrase.value = value
|
||||
}
|
||||
|
||||
/** Clear the passphrase from memory (on lock, timeout, or app-lock disable). */
|
||||
fun lock() {
|
||||
passphrase.value = null
|
||||
}
|
||||
|
||||
/** The current passphrase, or null while locked. */
|
||||
fun current(): String? = passphrase.value
|
||||
|
||||
fun isUnlocked(): Boolean = passphrase.value != null
|
||||
|
||||
/** Suspend until the session is unlocked, then return the passphrase. */
|
||||
suspend fun await(): String = passphrase.filterNotNull().first()
|
||||
}
|
||||
@@ -48,6 +48,7 @@ data class AppSettings(
|
||||
val allowStartTls: Boolean = false,
|
||||
val loadRemoteImages: Boolean = false,
|
||||
val encryptCache: Boolean = false,
|
||||
val appLock: Boolean = false,
|
||||
val includeInBackup: Boolean = false,
|
||||
val fetchPolicy: FetchPolicy = FetchPolicy.WIFI_ONLY,
|
||||
/**
|
||||
@@ -65,6 +66,7 @@ private object Keys {
|
||||
val ALLOW_STARTTLS = booleanPreferencesKey("allow_starttls")
|
||||
val LOAD_REMOTE_IMAGES = booleanPreferencesKey("load_remote_images")
|
||||
val ENCRYPT_CACHE = booleanPreferencesKey("encrypt_cache")
|
||||
val APP_LOCK = booleanPreferencesKey("app_lock")
|
||||
val INCLUDE_IN_BACKUP = booleanPreferencesKey("include_in_backup")
|
||||
val FETCH_POLICY = stringPreferencesKey("fetch_policy")
|
||||
val RETENTION_COUNT = intPreferencesKey("retention_count")
|
||||
@@ -83,6 +85,7 @@ internal fun Preferences.toAppSettings(): AppSettings = AppSettings(
|
||||
allowStartTls = this[Keys.ALLOW_STARTTLS] ?: false,
|
||||
loadRemoteImages = this[Keys.LOAD_REMOTE_IMAGES] ?: false,
|
||||
encryptCache = this[Keys.ENCRYPT_CACHE] ?: false,
|
||||
appLock = this[Keys.APP_LOCK] ?: false,
|
||||
includeInBackup = this[Keys.INCLUDE_IN_BACKUP] ?: false,
|
||||
// The fallback here (not just the AppSettings default) must be WIFI_ONLY so existing installs
|
||||
// that never touched the setting also pick up the safer default (#88). An explicitly chosen
|
||||
@@ -121,6 +124,7 @@ class SettingsRepository @Inject constructor(@ApplicationContext private val con
|
||||
suspend fun setAllowStartTls(value: Boolean) = put(Keys.ALLOW_STARTTLS, value)
|
||||
suspend fun setLoadRemoteImages(value: Boolean) = put(Keys.LOAD_REMOTE_IMAGES, value)
|
||||
suspend fun setEncryptCache(value: Boolean) = put(Keys.ENCRYPT_CACHE, value)
|
||||
suspend fun setAppLock(value: Boolean) = put(Keys.APP_LOCK, value)
|
||||
|
||||
/**
|
||||
* Opts this app in/out of system Android Backup. Off by default. After persisting, nudges the
|
||||
|
||||
@@ -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() },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import net.zetetic.database.sqlcipher.SupportOpenHelperFactory
|
||||
import org.libremail.data.local.DatabaseEncryption
|
||||
import org.libremail.data.local.DatabaseFiles
|
||||
import org.libremail.data.local.LibreMailDatabase
|
||||
import org.libremail.data.local.MIGRATION_10_11
|
||||
import org.libremail.data.local.MIGRATION_11_12
|
||||
@@ -74,16 +75,41 @@ object DatabaseModule {
|
||||
// Opt-in at-rest encryption of the local cache (off by default). The conversion runs here —
|
||||
// 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.
|
||||
//
|
||||
// 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)
|
||||
if (runBlocking { settingsRepository.settings.first().encryptCache }) {
|
||||
val passphrase = runBlocking { keyStore.passphrase() }
|
||||
|
||||
// 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. 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()
|
||||
keyStore.clearClearPending()
|
||||
}
|
||||
}
|
||||
|
||||
val settings = runBlocking { settingsRepository.settings.first() }
|
||||
val appLock = settings.appLock
|
||||
if (settings.encryptCache) {
|
||||
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.
|
||||
DatabaseEncryption.ensurePlaintext(dbFile, runBlocking { keyStore.passphrase() })
|
||||
val passphrase = runBlocking { keyStore.resolvePassphrase(appLock) }
|
||||
DatabaseEncryption.ensurePlaintext(dbFile, passphrase)
|
||||
}
|
||||
return builder.build()
|
||||
}
|
||||
@@ -118,5 +144,5 @@ object DatabaseModule {
|
||||
@Provides
|
||||
fun provideBackfillProgressDao(database: LibreMailDatabase): BackfillProgressDao = database.backfillProgressDao()
|
||||
|
||||
private const val DB_NAME = "libremail.db"
|
||||
private const val DB_NAME = DatabaseFiles.NAME
|
||||
}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
package org.libremail.di
|
||||
|
||||
import dagger.Binds
|
||||
import dagger.Module
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import org.libremail.data.security.AndroidAppLockManager
|
||||
import org.libremail.data.security.AppLockManager
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
abstract class SecurityModule {
|
||||
|
||||
@Binds
|
||||
@Singleton
|
||||
abstract fun bindAppLockManager(impl: AndroidAppLockManager): AppLockManager
|
||||
}
|
||||
@@ -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
|
||||
@@ -27,6 +28,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.data.sync.PushMode
|
||||
@@ -54,13 +56,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
|
||||
|
||||
@Inject lateinit var batteryStatusProvider: BatteryStatusProvider
|
||||
|
||||
@@ -80,7 +87,16 @@ class IdleService : Service() {
|
||||
startAsForeground(shownMode)
|
||||
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
|
||||
}
|
||||
@@ -95,7 +111,7 @@ class IdleService : Service() {
|
||||
*/
|
||||
private suspend fun reconcileWatchers() {
|
||||
val pushModes = SyncResourcePolicy.pushModes(batteryStatusProvider.current(), batteryStatusProvider.status())
|
||||
combine(accountDao.observeAll(), pushModes) { entities, mode ->
|
||||
combine(accountDao.get().observeAll(), pushModes) { entities, mode ->
|
||||
entities.map { it.toDomain() } to mode
|
||||
}.collect { (accounts, mode) ->
|
||||
if (mode != shownMode) {
|
||||
@@ -138,10 +154,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) {
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
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
|
||||
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
|
||||
import androidx.fragment.app.FragmentActivity
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleEventObserver
|
||||
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import org.libremail.R
|
||||
import org.libremail.data.security.AppLockManager
|
||||
|
||||
/**
|
||||
* Wraps the whole app in the screen-lock gate. While the app is locked it shows [LockScreen] and
|
||||
* presents a `BiometricPrompt` (strong biometric with device-credential fallback); [content] — the
|
||||
* real app — is composed only once [AppLockViewModel] reports [AppLockUiState.Unlocked], which also
|
||||
* guarantees the encrypted cache's passphrase has been unwrapped before any DB-backed screen opens.
|
||||
*
|
||||
* Must be hosted by a [FragmentActivity]; `BiometricPrompt` requires one.
|
||||
*/
|
||||
@Composable
|
||||
fun AppLockGateHost(viewModel: AppLockViewModel = hiltViewModel(), content: @Composable () -> Unit) {
|
||||
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
val lifecycleOwner = LocalLifecycleOwner.current
|
||||
val context = LocalContext.current
|
||||
val activity = context.findFragmentActivity()
|
||||
|
||||
DisposableEffect(lifecycleOwner) {
|
||||
val observer = LifecycleEventObserver { _, event ->
|
||||
when (event) {
|
||||
Lifecycle.Event.ON_START -> viewModel.onForeground()
|
||||
Lifecycle.Event.ON_STOP -> viewModel.onBackground()
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
lifecycleOwner.lifecycle.addObserver(observer)
|
||||
onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }
|
||||
}
|
||||
|
||||
val promptTitle = stringResource(R.string.app_lock_prompt_title)
|
||||
val promptSubtitle = stringResource(R.string.app_lock_prompt_subtitle)
|
||||
val authenticate: () -> Unit = authenticate@{
|
||||
val host = activity ?: run {
|
||||
viewModel.onAuthError(null)
|
||||
return@authenticate
|
||||
}
|
||||
host.showAppLockPrompt(
|
||||
title = promptTitle,
|
||||
subtitle = promptSubtitle,
|
||||
onSuccess = viewModel::onAuthenticated,
|
||||
onError = { message -> viewModel.onAuthError(message) },
|
||||
)
|
||||
}
|
||||
|
||||
// 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 }
|
||||
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
if (hasEverUnlocked) content()
|
||||
|
||||
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
|
||||
* an allowed authenticator. DEVICE-ONLY behavior; cannot be exercised in JVM unit tests.
|
||||
*/
|
||||
private fun FragmentActivity.showAppLockPrompt(
|
||||
title: String,
|
||||
subtitle: String,
|
||||
onSuccess: () -> Unit,
|
||||
onError: (String?) -> Unit,
|
||||
) {
|
||||
val executor = ContextCompat.getMainExecutor(this)
|
||||
val prompt = BiometricPrompt(
|
||||
this,
|
||||
executor,
|
||||
object : BiometricPrompt.AuthenticationCallback() {
|
||||
override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
|
||||
onSuccess()
|
||||
}
|
||||
|
||||
override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {
|
||||
onError(errString.toString())
|
||||
}
|
||||
},
|
||||
)
|
||||
val info = BiometricPrompt.PromptInfo.Builder()
|
||||
.setTitle(title)
|
||||
.setSubtitle(subtitle)
|
||||
.setAllowedAuthenticators(AppLockManager.AUTHENTICATORS)
|
||||
.build()
|
||||
prompt.authenticate(info)
|
||||
}
|
||||
|
||||
private tailrec fun android.content.Context.findFragmentActivity(): FragmentActivity? = when (this) {
|
||||
is FragmentActivity -> this
|
||||
is android.content.ContextWrapper -> baseContext.findFragmentActivity()
|
||||
else -> null
|
||||
}
|
||||
@@ -0,0 +1,268 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
package org.libremail.ui.lock
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.SystemClock
|
||||
import android.security.keystore.KeyPermanentlyInvalidatedException
|
||||
import android.security.keystore.UserNotAuthenticatedException
|
||||
import android.util.Log
|
||||
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
|
||||
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
|
||||
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.SettingsRepository
|
||||
import org.libremail.data.sync.SyncScheduler
|
||||
import javax.inject.Inject
|
||||
|
||||
/** UI state of the app-lock gate that wraps the whole app. */
|
||||
sealed interface AppLockUiState {
|
||||
/** Still resolving settings / device state — render a blank surface, never the app content. */
|
||||
data object Checking : 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. [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
|
||||
}
|
||||
|
||||
/**
|
||||
* Drives the app-lock gate: reconciles the app-lock / encrypted-cache settings with device state on
|
||||
* every foreground pass, requests authentication, and on success unwraps the auth-bound SQLCipher
|
||||
* passphrase into [PassphraseSession] so the encrypted cache becomes readable.
|
||||
*
|
||||
* Key invalidation (biometric re-enrollment or lock removal) is handled by "clear + re-sync, never
|
||||
* corrupt": the cache cannot be deleted here because Room may hold it open, so we persist a flag and
|
||||
* restart the process — `DatabaseModule.provideDatabase` wipes the file at the next cold start, before
|
||||
* Room opens it. The [AppLockGate] state machine and [KeyInvalidationPolicy] decision table (both
|
||||
* pure) carry the security-critical branching and are unit-tested; this ViewModel wires them to
|
||||
* Android/crypto.
|
||||
*
|
||||
* Threading: coroutines run on the main dispatcher so [gate] (also touched by [onBackground] from the
|
||||
* lifecycle callback) is only ever mutated on the main thread; blocking Keystore/DataStore work is
|
||||
* pushed to [Dispatchers.Default] via [withContext].
|
||||
*/
|
||||
@HiltViewModel
|
||||
class AppLockViewModel @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
private val settingsRepository: SettingsRepository,
|
||||
private val appLockManager: AppLockManager,
|
||||
private val databaseKeyStore: DatabaseKeyStore,
|
||||
private val databaseKeyCipher: DatabaseKeyCipher,
|
||||
private val session: PassphraseSession,
|
||||
private val syncScheduler: SyncScheduler,
|
||||
) : ViewModel() {
|
||||
|
||||
private val gate = AppLockGate()
|
||||
|
||||
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 = true,
|
||||
encryptCacheEnabled = settings.encryptCache,
|
||||
deviceSecure = appLockManager.isDeviceSecure(),
|
||||
keyInvalidated = databaseKeyCipher.isInvalidated(),
|
||||
)
|
||||
}
|
||||
when (action) {
|
||||
LockAction.PROCEED -> _uiState.value = AppLockUiState.Unlocked
|
||||
|
||||
LockAction.DISABLE_APP_LOCK -> {
|
||||
settingsRepository.setAppLock(false)
|
||||
_uiState.value = AppLockUiState.Unlocked
|
||||
}
|
||||
|
||||
LockAction.CLEAR_AND_DISABLE -> clearCacheAndRestart(disableAppLock = true)
|
||||
|
||||
LockAction.CLEAR_AND_REQUIRE_AUTH -> clearCacheAndRestart(disableAppLock = false)
|
||||
|
||||
LockAction.REQUIRE_AUTH -> {
|
||||
val state = gate.onForeground(foregroundAt, appLockEnabled = true)
|
||||
if (state == LockState.UNLOCKED) _uiState.value = AppLockUiState.Unlocked else emitLocked()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 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`. */
|
||||
fun onAuthenticated() {
|
||||
viewModelScope.launch {
|
||||
when (withContext(Dispatchers.Default) { unlockOrArm() }) {
|
||||
UnlockResult.OK -> {
|
||||
gate.onAuthenticated()
|
||||
_uiState.value = AppLockUiState.Unlocked
|
||||
}
|
||||
|
||||
UnlockResult.UNRECOVERABLE -> {
|
||||
// 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(disableAppLock = false)
|
||||
}
|
||||
|
||||
UnlockResult.RETRY -> {
|
||||
gate.lock()
|
||||
emitLocked(context.getString(R.string.app_lock_unlock_failed))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Called by the host when the prompt is cancelled or errors. */
|
||||
fun onAuthError(message: String?) {
|
||||
gate.lock()
|
||||
emitLocked(message)
|
||||
}
|
||||
|
||||
/** Outcome of unwrapping/arming the auth-bound passphrase after a successful device auth. */
|
||||
private enum class UnlockResult { OK, UNRECOVERABLE, RETRY }
|
||||
|
||||
/**
|
||||
* Reconcile the auth-bound passphrase with the encrypted cache after a successful device auth:
|
||||
* - warm re-auth (session already holds the passphrase, DB already open): nothing to do;
|
||||
* - a sealed passphrase exists: unwrap it into the session — this covers the normal encrypted
|
||||
* cache AND the transitional case where encryptCache was just turned off but the on-disk DB is
|
||||
* still encrypted and `provideDatabase` needs the passphrase to decrypt it to plaintext;
|
||||
* - no seal yet AND encryptCache is on: first-time arm within this valid auth window;
|
||||
* - no seal and encryptCache off: app-lock is a pure UI gate this session — nothing to unlock.
|
||||
*
|
||||
* Failures are classified so we only wipe the cache when the passphrase is truly lost: a
|
||||
* permanently-invalidated OR deleted key is [UNRECOVERABLE]; a transient auth error (e.g. the
|
||||
* short key-validity window elapsed) is a [RETRY].
|
||||
*/
|
||||
private suspend fun unlockOrArm(): UnlockResult {
|
||||
if (session.isUnlocked()) return UnlockResult.OK
|
||||
if (databaseKeyStore.hasAuthSealedPassphrase()) return unwrapSealedPassphrase()
|
||||
// No auth seal yet: arm one only when there is (or will be) an encrypted cache to protect.
|
||||
if (!settingsRepository.settings.first().encryptCache) return UnlockResult.OK
|
||||
return runCatching { databaseKeyStore.sealWithAuth() }.fold(
|
||||
onSuccess = { UnlockResult.OK },
|
||||
onFailure = { e ->
|
||||
Log.w(TAG, "arming auth seal failed", e)
|
||||
UnlockResult.RETRY
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun unwrapSealedPassphrase(): UnlockResult {
|
||||
// A sealed passphrase exists but its key is gone entirely: it can never be unwrapped.
|
||||
if (!databaseKeyCipher.hasKey()) {
|
||||
Log.w(TAG, "auth-sealed passphrase present but key was deleted; cache unrecoverable")
|
||||
return UnlockResult.UNRECOVERABLE
|
||||
}
|
||||
return try {
|
||||
databaseKeyStore.unlockWithAuth()
|
||||
UnlockResult.OK
|
||||
} catch (e: KeyPermanentlyInvalidatedException) {
|
||||
Log.w(TAG, "auth-bound key permanently invalidated", e)
|
||||
UnlockResult.UNRECOVERABLE
|
||||
} 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(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()
|
||||
}
|
||||
|
||||
/**
|
||||
* Relaunch the app in a fresh process so [org.libremail.di.DatabaseModule] wipes the cache before
|
||||
* Room reopens it. DEVICE-ONLY: process restart cannot be exercised in JVM unit tests.
|
||||
*/
|
||||
private fun restartProcess() {
|
||||
val intent = context.packageManager.getLaunchIntentForPackage(context.packageName)
|
||||
?.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK)
|
||||
if (intent != null) context.startActivity(intent)
|
||||
Runtime.getRuntime().exit(0)
|
||||
}
|
||||
|
||||
// Monotonic clock so a wall-clock change can't extend the inactivity grace window.
|
||||
private fun now(): Long = SystemClock.elapsedRealtime()
|
||||
|
||||
private companion object {
|
||||
const val TAG = "LibreMailAppLock"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
package org.libremail.ui.lock
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import org.libremail.R
|
||||
|
||||
/**
|
||||
* Full-screen lock gate shown while the app is locked. Deliberately renders no mailbox content so
|
||||
* nothing sensitive is visible before authentication. [onUnlock] triggers the `BiometricPrompt`.
|
||||
*/
|
||||
@Composable
|
||||
fun LockScreen(error: String?, onUnlock: () -> Unit) {
|
||||
Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(32.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center,
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.app_lock_title),
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.app_lock_locked_body),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
if (error != null) {
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text(
|
||||
text = error,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.height(24.dp))
|
||||
Button(onClick = onUnlock) {
|
||||
Text(stringResource(R.string.app_lock_unlock))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
package org.libremail.ui.settings
|
||||
|
||||
import android.widget.Toast
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Column
|
||||
@@ -20,11 +21,13 @@ import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.rotate
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalResources
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
@@ -48,8 +51,17 @@ fun SettingsScreen(
|
||||
val settings by viewModel.settings.collectAsStateWithLifecycle()
|
||||
val accounts by viewModel.accounts.collectAsStateWithLifecycle()
|
||||
val advancedExpanded by viewModel.advancedExpanded.collectAsStateWithLifecycle()
|
||||
val appLockMessage by viewModel.appLockMessage.collectAsStateWithLifecycle()
|
||||
val batteryUnrestricted by viewModel.batteryUnrestricted.collectAsStateWithLifecycle()
|
||||
val context = LocalContext.current
|
||||
val resources = LocalResources.current
|
||||
|
||||
LaunchedEffect(appLockMessage) {
|
||||
appLockMessage?.let {
|
||||
Toast.makeText(context, resources.getString(it), Toast.LENGTH_LONG).show()
|
||||
viewModel.clearAppLockMessage()
|
||||
}
|
||||
}
|
||||
|
||||
// Re-read the battery status on resume so it reflects any change made in system settings.
|
||||
LifecycleEventEffect(Lifecycle.Event.ON_RESUME) { viewModel.refreshBatteryStatus() }
|
||||
@@ -182,6 +194,12 @@ fun SettingsScreen(
|
||||
onCheckedChange = viewModel::setEncryptCache,
|
||||
subtitle = stringResource(R.string.settings_adv_encrypt_cache_summary),
|
||||
)
|
||||
SwitchRow(
|
||||
title = stringResource(R.string.settings_adv_app_lock),
|
||||
checked = settings.appLock,
|
||||
onCheckedChange = viewModel::setAppLock,
|
||||
subtitle = stringResource(R.string.settings_adv_app_lock_summary),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,9 @@ import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
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.FetchPolicy
|
||||
import org.libremail.data.settings.SettingsRepository
|
||||
@@ -25,6 +28,8 @@ import javax.inject.Inject
|
||||
class SettingsViewModel @Inject constructor(
|
||||
private val accountRepository: AccountRepository,
|
||||
private val settingsRepository: SettingsRepository,
|
||||
private val appLockManager: AppLockManager,
|
||||
private val databaseKeyStore: DatabaseKeyStore,
|
||||
private val batteryOptimizationManager: BatteryOptimizationManager,
|
||||
private val syncScheduler: SyncScheduler,
|
||||
) : ViewModel() {
|
||||
@@ -38,6 +43,10 @@ class SettingsViewModel @Inject constructor(
|
||||
private val _advancedExpanded = MutableStateFlow(false)
|
||||
val advancedExpanded: StateFlow<Boolean> = _advancedExpanded.asStateFlow()
|
||||
|
||||
/** Non-null when a toggle was rejected; a string resource id the screen can surface, then clear. */
|
||||
private val _appLockMessage = MutableStateFlow<Int?>(null)
|
||||
val appLockMessage: StateFlow<Int?> = _appLockMessage.asStateFlow()
|
||||
|
||||
private val _batteryUnrestricted = MutableStateFlow(batteryOptimizationManager.isIgnoringBatteryOptimizations())
|
||||
|
||||
/** Whether this app is exempt from battery optimization ("Unrestricted"). */
|
||||
@@ -62,6 +71,38 @@ class SettingsViewModel @Inject constructor(
|
||||
fun setIncludeInBackup(value: Boolean) = update { settingsRepository.setIncludeInBackup(value) }
|
||||
fun setFetchPolicy(value: FetchPolicy) = update { settingsRepository.setFetchPolicy(value) }
|
||||
|
||||
/**
|
||||
* Toggle app-lock. Enabling requires a secure device lock (otherwise there is nothing to
|
||||
* authenticate against) — rejected with a message if absent. Disabling reseals the cache
|
||||
* passphrase with the non-auth master key so it stays readable without authentication; the user
|
||||
* is already authenticated for this session (they passed the gate to reach settings).
|
||||
*/
|
||||
fun setAppLock(value: Boolean) = update {
|
||||
if (value) {
|
||||
if (!appLockManager.isDeviceSecure()) {
|
||||
_appLockMessage.value = R.string.app_lock_needs_device_lock
|
||||
return@update
|
||||
}
|
||||
settingsRepository.setAppLock(true)
|
||||
} else {
|
||||
// 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
|
||||
}
|
||||
}
|
||||
settingsRepository.setAppLock(false)
|
||||
}
|
||||
}
|
||||
|
||||
fun clearAppLockMessage() {
|
||||
_appLockMessage.value = null
|
||||
}
|
||||
|
||||
/** Global retention defaults; kick a prune so a newly-tightened limit takes effect promptly (#13). */
|
||||
fun setRetentionCount(value: Int) = update {
|
||||
settingsRepository.setRetentionCount(value)
|
||||
|
||||
@@ -257,6 +257,19 @@
|
||||
<string name="settings_adv_remote_images">Load remote images by default</string>
|
||||
<string name="settings_adv_encrypt_cache">Encrypt local cache</string>
|
||||
<string name="settings_adv_encrypt_cache_summary">Encrypt cached mail stored on this device, using a key held in the Android Keystore. Applies the next time you open LibreMail.</string>
|
||||
<string name="settings_adv_app_lock">Require screen lock</string>
|
||||
<string name="settings_adv_app_lock_summary">Require your device screen lock (fingerprint, face, PIN, pattern, or password) to open LibreMail. When the local cache is encrypted, it is only decrypted after you authenticate. Applies the next time you open LibreMail. You must have a device lock set up first.</string>
|
||||
|
||||
<!-- App lock -->
|
||||
<string name="app_lock_title">LibreMail is locked</string>
|
||||
<string name="app_lock_locked_body">Authenticate with your device screen lock to continue.</string>
|
||||
<string name="app_lock_unlock">Unlock</string>
|
||||
<string name="app_lock_prompt_title">Unlock LibreMail</string>
|
||||
<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>
|
||||
<string name="settings_adv_battery_optimized">Optimized by Android — new mail may be delayed. Tap to allow unrestricted background usage.</string>
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
package org.libremail.data.security
|
||||
|
||||
import org.junit.Test
|
||||
import kotlin.test.assertEquals
|
||||
|
||||
class AppLockGateTest {
|
||||
|
||||
private val grace = 30_000L
|
||||
|
||||
@Test
|
||||
fun `starts locked`() {
|
||||
assertEquals(LockState.LOCKED, AppLockGate(grace).state)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `app-lock disabled always unlocks`() {
|
||||
val gate = AppLockGate(grace)
|
||||
assertEquals(LockState.UNLOCKED, gate.onForeground(now = 0, appLockEnabled = false))
|
||||
assertEquals(LockState.UNLOCKED, gate.state)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `cold start with app-lock on stays locked until authenticated`() {
|
||||
val gate = AppLockGate(grace)
|
||||
assertEquals(LockState.LOCKED, gate.onForeground(now = 0, appLockEnabled = true))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `authentication unlocks`() {
|
||||
val gate = AppLockGate(grace)
|
||||
gate.onForeground(now = 0, appLockEnabled = true)
|
||||
gate.onAuthenticated()
|
||||
assertEquals(LockState.UNLOCKED, gate.state)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `returning within grace stays unlocked`() {
|
||||
val gate = AppLockGate(grace)
|
||||
gate.onForeground(now = 0, appLockEnabled = true)
|
||||
gate.onAuthenticated()
|
||||
gate.onBackground(now = 1_000)
|
||||
assertEquals(LockState.UNLOCKED, gate.onForeground(now = 1_000 + grace, appLockEnabled = true))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `returning after grace re-locks`() {
|
||||
val gate = AppLockGate(grace)
|
||||
gate.onForeground(now = 0, appLockEnabled = true)
|
||||
gate.onAuthenticated()
|
||||
gate.onBackground(now = 1_000)
|
||||
assertEquals(LockState.LOCKED, gate.onForeground(now = 1_000 + grace + 1, appLockEnabled = true))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `foreground without backgrounding keeps an unlocked session (e g rotation)`() {
|
||||
val gate = AppLockGate(grace)
|
||||
gate.onForeground(now = 0, appLockEnabled = true)
|
||||
gate.onAuthenticated()
|
||||
// ON_START fires again on a configuration change without an intervening ON_STOP.
|
||||
assertEquals(LockState.UNLOCKED, gate.onForeground(now = 5_000, appLockEnabled = true))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `lock forces a re-authentication`() {
|
||||
val gate = AppLockGate(grace)
|
||||
gate.onForeground(now = 0, appLockEnabled = true)
|
||||
gate.onAuthenticated()
|
||||
gate.lock()
|
||||
assertEquals(LockState.LOCKED, gate.state)
|
||||
// A subsequent foreground must not silently unlock a force-locked gate.
|
||||
assertEquals(LockState.LOCKED, gate.onForeground(now = 100, appLockEnabled = true))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `disabling app-lock unlocks even a force-locked gate`() {
|
||||
val gate = AppLockGate(grace)
|
||||
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))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
package org.libremail.data.security
|
||||
|
||||
import org.junit.Test
|
||||
import kotlin.test.assertEquals
|
||||
|
||||
class KeyInvalidationPolicyTest {
|
||||
|
||||
private fun decide(
|
||||
appLock: Boolean = true,
|
||||
encrypt: Boolean = true,
|
||||
secure: Boolean = true,
|
||||
invalidated: Boolean = false,
|
||||
) = KeyInvalidationPolicy.decide(appLock, encrypt, secure, invalidated)
|
||||
|
||||
@Test
|
||||
fun `app-lock off proceeds`() {
|
||||
assertEquals(LockAction.PROCEED, decide(appLock = false))
|
||||
assertEquals(LockAction.PROCEED, decide(appLock = false, secure = false, invalidated = true))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `healthy secure device with valid key requires auth`() {
|
||||
assertEquals(LockAction.REQUIRE_AUTH, decide())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `lock removed with encrypted cache clears and disables`() {
|
||||
// The auth-bound passphrase is unrecoverable; wipe the cache and drop the gate.
|
||||
assertEquals(LockAction.CLEAR_AND_DISABLE, decide(secure = false, encrypt = true))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `lock removed without encrypted cache just disables`() {
|
||||
assertEquals(LockAction.DISABLE_APP_LOCK, decide(secure = false, encrypt = false))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `biometric re-enrollment with encrypted cache clears and re-requires auth`() {
|
||||
assertEquals(LockAction.CLEAR_AND_REQUIRE_AUTH, decide(invalidated = true, encrypt = true))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `biometric re-enrollment without encrypted cache re-requires auth without clearing`() {
|
||||
// Nothing encrypted to lose; a fresh key is minted on the next successful unlock.
|
||||
assertEquals(LockAction.REQUIRE_AUTH, decide(invalidated = true, encrypt = false))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `lock removal takes precedence over key-invalidation flag`() {
|
||||
// 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))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
package org.libremail.data.security
|
||||
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlinx.coroutines.yield
|
||||
import org.junit.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class PassphraseSessionTest {
|
||||
|
||||
@Test
|
||||
fun `starts locked`() {
|
||||
val session = PassphraseSession()
|
||||
assertFalse(session.isUnlocked())
|
||||
assertNull(session.current())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `unlock stores and lock clears`() {
|
||||
val session = PassphraseSession()
|
||||
session.unlock("deadbeef")
|
||||
assertTrue(session.isUnlocked())
|
||||
assertEquals("deadbeef", session.current())
|
||||
session.lock()
|
||||
assertFalse(session.isUnlocked())
|
||||
assertNull(session.current())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `await returns immediately when already unlocked`() = runTest {
|
||||
val session = PassphraseSession()
|
||||
session.unlock("cafe")
|
||||
assertEquals("cafe", session.await())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `await suspends until unlocked`() = runTest {
|
||||
val session = PassphraseSession()
|
||||
val awaited = async { session.await() }
|
||||
yield()
|
||||
assertFalse(awaited.isCompleted) // still waiting
|
||||
session.unlock("f00d")
|
||||
assertEquals("f00d", awaited.await())
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ lifecycle = "2.9.4"
|
||||
activityCompose = "1.12.4"
|
||||
navigationCompose = "2.9.8"
|
||||
webkit = "1.12.1"
|
||||
biometric = "1.1.0"
|
||||
composeBom = "2026.06.00"
|
||||
room = "2.8.4"
|
||||
sqlcipher = "4.16.0"
|
||||
@@ -42,6 +43,8 @@ androidx-lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "l
|
||||
androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
|
||||
androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigationCompose" }
|
||||
androidx-webkit = { group = "androidx.webkit", name = "webkit", version.ref = "webkit" }
|
||||
# Biometric / device-credential auth for the opt-in app-lock + encrypted-cache unlock.
|
||||
androidx-biometric = { group = "androidx.biometric", name = "biometric", version.ref = "biometric" }
|
||||
|
||||
# Compose (versions managed by the BOM)
|
||||
androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
|
||||
|
||||
Reference in New Issue
Block a user