diff --git a/README.md b/README.md index 65033e2..2c3f3ec 100644 --- a/README.md +++ b/README.md @@ -75,7 +75,8 @@ next work. Every row below cites the file or test that proves it. | Onboarding, Today, Calendar, Insights | Built | `app/src/main/kotlin/dev/privacyllc/period/feature/` — onboarding, today, calendar, insights; `OnboardingViewModelTest`, `TodayViewModelTest`, `TodayUiStateTest` | | Fertility window and ovulation estimate | Built | `domain/prediction/src/main/kotlin/dev/privacyllc/period/domain/prediction/FertilityEstimate.kt`, 11 tests; shown on Today and Calendar | | Discreet notifications | Built | `core/notifications` (24 JVM tests), instrumented `NotificationPrivacyTest` | -| App lock, export, irreversible delete, monetization | Not built | Batches 06–07 | +| App lock (PIN + fingerprint) | Built | `core/security` (Keystore-backed verifier, lockout policy), `app/src/main/kotlin/dev/privacyllc/period/lock` gate; 28 JVM tests plus `KeystoreVerifierTest` run on `PeriodMinSdk26` and `PeriodQA` | +| Export, monetization | Not built | Batches 06–07 | | QA | Round 3 run, partial | [docs/qa/ClaudeReport.md](docs/qa/ClaudeReport.md) — partial at `0451fbe`; TalkBack, text scaling, `minSdk` and a real locked screen still unreached | `BaselinePredictionEngine` is still in the tree, but it stopped being the diff --git a/app/build.gradle.kts b/app/build.gradle.kts index b91e287..ac7260d 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -59,6 +59,7 @@ dependencies { implementation(project(":core:data")) implementation(project(":core:datastore")) implementation(project(":core:notifications")) + implementation(project(":core:security")) implementation(project(":domain:cycle")) implementation(project(":domain:prediction")) @@ -82,6 +83,10 @@ dependencies { implementation(libs.hilt.navigation.compose) implementation(libs.androidx.hilt.work) implementation(libs.androidx.work.runtime) + implementation(libs.androidx.biometric) + // Pinned so MainActivity's FragmentActivity comes from a current fragment + // rather than the 1.5.1 that androidx.biometric 1.1.0's graph settles on. + implementation(libs.androidx.fragment) ksp(libs.hilt.compiler) ksp(libs.androidx.hilt.compiler) diff --git a/app/src/main/kotlin/dev/privacyllc/period/MainActivity.kt b/app/src/main/kotlin/dev/privacyllc/period/MainActivity.kt index f41cb3c..585d628 100644 --- a/app/src/main/kotlin/dev/privacyllc/period/MainActivity.kt +++ b/app/src/main/kotlin/dev/privacyllc/period/MainActivity.kt @@ -1,26 +1,103 @@ package dev.privacyllc.period +import android.content.Intent import android.os.Bundle -import androidx.activity.ComponentActivity +import android.view.WindowManager import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge +import androidx.fragment.app.FragmentActivity +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle import dagger.hilt.android.AndroidEntryPoint +import dev.privacyllc.period.core.security.AppLockRepository import dev.privacyllc.period.designsystem.PeriodTheme +import dev.privacyllc.period.lock.AppLockController +import dev.privacyllc.period.lock.AppLockGate import dev.privacyllc.period.navigation.PeriodRoot +import kotlinx.coroutines.launch +import javax.inject.Inject +/** + * A [FragmentActivity], and not by preference. + * + * Every `BiometricPrompt` constructor in androidx.biometric 1.1.0 takes a + * `FragmentActivity` or a `Fragment`; there is no `ComponentActivity` overload. + * `FragmentActivity` extends `ComponentActivity`, so `enableEdgeToEdge`, + * `setContent`, `@AndroidEntryPoint` and `hiltViewModel()` are all unaffected. + * Only 1.4.0-alpha avoids this, and an alpha in the component that decides + * whether the app opens is the wrong trade for this product. + */ @AndroidEntryPoint -class MainActivity : ComponentActivity() { +class MainActivity : FragmentActivity() { + + @Inject lateinit var lockController: AppLockController + + @Inject lateinit var appLock: AppLockRepository + override fun onCreate(savedInstanceState: Bundle?) { + // Set BEFORE anything can be drawn, and cleared later only once the + // store has confirmed there is no lock. Reading the preference first + // would mean disk I/O on the startup path; starting insecure and + // tightening afterwards would mean a frame of real content in the + // recents thumbnail, which is exactly the leak this prevents. + window.setFlags( + WindowManager.LayoutParams.FLAG_SECURE, + WindowManager.LayoutParams.FLAG_SECURE, + ) + enableEdgeToEdge() super.onCreate(savedInstanceState) + + // Parked, never applied here. With a lock on, the write waits for the + // unlock; the gate delivers it. See AppLockController. + lockController.holdNotificationAction(intent?.getStringExtra(EXTRA_REMINDER_ACTION)) + + lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.STARTED) { + appLock.hasPin.collect(::applySecureFlag) + } + } + setContent { PeriodTheme { - // The action tapped on a notification, if the app was opened by - // one. Nothing is shown differently because of it — the answer - // is recorded and the user lands on the normal screen, behind - // whatever device lock they have (§31). - PeriodRoot(notificationAction = intent?.getStringExtra("reminder_action")) + AppLockGate { + PeriodRoot() + } } } } + + /** + * A notification tapped while the app is already open comes here rather than + * through `onCreate`, so without this the action would be dropped. + */ + override fun onNewIntent(intent: Intent) { + super.onNewIntent(intent) + setIntent(intent) + lockController.holdNotificationAction(intent.getStringExtra(EXTRA_REMINDER_ACTION)) + } + + /** + * Conditioned on the lock being set, never on the build type. + * + * A debug-only exemption would mean the release-only behaviour is the one + * nobody ever sees, and a recents leak is exactly the defect that hides + * there. The consequence — no screenshots while the lock is on — is stated + * in Settings so it is a choice rather than a surprise. + */ + private fun applySecureFlag(locked: Boolean) { + if (locked) { + window.setFlags( + WindowManager.LayoutParams.FLAG_SECURE, + WindowManager.LayoutParams.FLAG_SECURE, + ) + } else { + window.clearFlags(WindowManager.LayoutParams.FLAG_SECURE) + } + } + + private companion object { + const val EXTRA_REMINDER_ACTION = "reminder_action" + } } diff --git a/app/src/main/kotlin/dev/privacyllc/period/di/DataModule.kt b/app/src/main/kotlin/dev/privacyllc/period/di/DataModule.kt index 5a93be0..d8cef05 100644 --- a/app/src/main/kotlin/dev/privacyllc/period/di/DataModule.kt +++ b/app/src/main/kotlin/dev/privacyllc/period/di/DataModule.kt @@ -14,9 +14,11 @@ import dev.privacyllc.period.core.data.CycleData import dev.privacyllc.period.core.data.CycleRepository import dev.privacyllc.period.core.datastore.UserPreferencesRepository import dev.privacyllc.period.core.notifications.ReminderScheduler +import dev.privacyllc.period.core.security.AppLockRepository import dev.privacyllc.period.domain.prediction.PersonalPredictionEngine import dev.privacyllc.period.domain.prediction.PredictionEngine import java.time.Clock +import javax.inject.Qualifier import javax.inject.Singleton /** @@ -54,6 +56,29 @@ object DataModule { @Singleton fun userPreferencesRepository(store: DataStore) = UserPreferencesRepository(store) + /** + * The app lock's own store, in its own file. + * + * Qualified because it is a second `DataStore` and Hilt would + * otherwise have two bindings for one type — but the separate *file* is the + * real point, not the qualifier. `UserPreferencesRepository.resetToDefaults()` + * is `edit { it.clear() }`; a verifier record sharing that store would be one + * future caller away from silent removal, and removing it locks the user out + * of their own history with no way back. + */ + @Provides + @Singleton + @AppLockStore + fun appLockDataStore(@ApplicationContext context: Context): DataStore = + PreferenceDataStoreFactory.create { + context.preferencesDataStoreFile("app_lock") + } + + @Provides + @Singleton + fun appLockRepository(@AppLockStore store: DataStore): AppLockRepository = + AppLockRepository(store) + /** * The engine, and the one line that decides which one the product ships. * @@ -83,3 +108,8 @@ object DataModule { fun reminderScheduler(@ApplicationContext context: Context): ReminderScheduler = ReminderScheduler(context) } + +/** Distinguishes the lock's store from the settings store; they are different files. */ +@Qualifier +@Retention(AnnotationRetention.BINARY) +annotation class AppLockStore diff --git a/app/src/main/kotlin/dev/privacyllc/period/feature/lock/ForgotPinScreen.kt b/app/src/main/kotlin/dev/privacyllc/period/feature/lock/ForgotPinScreen.kt new file mode 100644 index 0000000..aa6cf32 --- /dev/null +++ b/app/src/main/kotlin/dev/privacyllc/period/feature/lock/ForgotPinScreen.kt @@ -0,0 +1,200 @@ +package dev.privacyllc.period.feature.lock + +import android.content.res.Configuration +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.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.semantics.LiveRegionMode +import androidx.compose.ui.semantics.liveRegion +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardCapitalization +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import dev.privacyllc.period.designsystem.PeriodTheme + +/** + * Behind "Forgot your PIN?", and it is a whole screen rather than a dialog + * because it needs reading rather than dismissing. + * + * The copy tells the truth in the order that matters: this does not unlock + * anything, it destroys what is stored, and it brings nothing back. It also says + * the honest thing about the risk — anyone holding the phone can already do the + * same from Android's own settings — so the choice is informed rather than + * frightening. + * + * Confirmation is a typed word, not a press-and-hold. Typing is the more + * accessible of the two, and the button stays enabled with an inline error on + * press rather than being disabled, so somebody using a screen reader is told + * *why* rather than meeting a control that silently does nothing. + */ +@Composable +fun ForgotPinScreen( + onCancel: () -> Unit, + viewModel: LockEraseViewModel = hiltViewModel(), +) { + val state by viewModel.state.collectAsStateWithLifecycle() + ForgotPinContent( + state = state, + onCancel = onCancel, + onErase = viewModel::eraseEverything, + ) +} + +@Composable +internal fun ForgotPinContent( + state: EraseState, + onCancel: () -> Unit, + onErase: () -> Unit, +) { + var typed by remember { mutableStateOf("") } + var mismatch by remember { mutableStateOf(false) } + + Surface(modifier = Modifier.fillMaxSize()) { + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(24.dp), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.Start, + ) { + Text("There is no way to reset your PIN", style = MaterialTheme.typography.headlineMedium) + + Spacer(Modifier.height(16.dp)) + + Text( + "We cannot reset it for you, and there is no recovery code. That is " + + "deliberate: anything that could let us back in could let somebody " + + "else in too.", + style = MaterialTheme.typography.bodyLarge, + ) + + Spacer(Modifier.height(12.dp)) + + Text( + "The only way past this screen is to erase everything you have recorded " + + "in this app and start again. This does not unlock anything and it " + + "does not bring anything back.", + style = MaterialTheme.typography.bodyLarge, + ) + + Spacer(Modifier.height(12.dp)) + + Text( + "Anyone holding this phone could already do the same from Android's own " + + "settings, so this is that same erase and not an extra risk. Nothing " + + "is backed up anywhere, so there is no copy to restore either way.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + Spacer(Modifier.height(24.dp)) + + when (state) { + EraseState.DONE -> Text( + "Everything has been erased and the lock is off. You can start again.", + style = MaterialTheme.typography.bodyLarge, + modifier = Modifier.semantics { liveRegion = LiveRegionMode.Polite }, + ) + + EraseState.FAILED -> Text( + "Something went wrong and nothing was erased. Your records are intact.", + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.error, + modifier = Modifier.semantics { liveRegion = LiveRegionMode.Polite }, + ) + + else -> { + Text("Type $CONFIRM_WORD to confirm.", style = MaterialTheme.typography.bodyMedium) + Spacer(Modifier.height(8.dp)) + OutlinedTextField( + value = typed, + onValueChange = { typed = it; mismatch = false }, + singleLine = true, + enabled = state != EraseState.ERASING, + isError = mismatch, + label = { Text(CONFIRM_WORD) }, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Text, + capitalization = KeyboardCapitalization.Characters, + imeAction = ImeAction.Done, + ), + modifier = Modifier.fillMaxWidth(), + ) + if (mismatch) { + Text( + "Type $CONFIRM_WORD exactly to confirm.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + modifier = Modifier.semantics { liveRegion = LiveRegionMode.Polite }, + ) + } + + Spacer(Modifier.height(16.dp)) + + Button( + // Deliberately always enabled: a disabled button tells a + // screen-reader user nothing about why. + onClick = { + if (typed.trim().equals(CONFIRM_WORD, ignoreCase = true)) onErase() else mismatch = true + }, + enabled = state != EraseState.ERASING, + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.error, + contentColor = MaterialTheme.colorScheme.onError, + ), + modifier = Modifier.fillMaxWidth(), + ) { + Text("Erase everything and start over") + } + } + } + + Spacer(Modifier.height(8.dp)) + + TextButton(onClick = onCancel, modifier = Modifier.fillMaxWidth()) { + Text(if (state == EraseState.DONE) "Continue" else "Go back") + } + } + } +} + +private const val CONFIRM_WORD = "ERASE" + +@Preview(name = "Forgot PIN · light", showBackground = true, heightDp = 900) +@Preview( + name = "Forgot PIN · dark", + showBackground = true, + heightDp = 900, + uiMode = Configuration.UI_MODE_NIGHT_YES, +) +@Preview(name = "Forgot PIN · font 2.0", showBackground = true, heightDp = 1600, fontScale = 2.0f) +@Composable +private fun ForgotPinPreview() { + PeriodTheme { ForgotPinContent(state = EraseState.IDLE, onCancel = {}, onErase = {}) } +} diff --git a/app/src/main/kotlin/dev/privacyllc/period/feature/lock/LockCopy.kt b/app/src/main/kotlin/dev/privacyllc/period/feature/lock/LockCopy.kt new file mode 100644 index 0000000..a9102df --- /dev/null +++ b/app/src/main/kotlin/dev/privacyllc/period/feature/lock/LockCopy.kt @@ -0,0 +1,43 @@ +package dev.privacyllc.period.feature.lock + +/** + * Every word the lock screen can show, in one place so it can be checked. + * + * The lock screen is the app's only **semi-public** surface: it is what appears + * when the owner opens the app in front of somebody else, and what anybody who + * picks the phone up sees. So none of it may contain + * `NotificationCopy.SENSITIVE_WORDS`, and `LockCopyTest` asserts exactly that. + * + * The strings live here rather than inline because the obvious alternative — a + * source-grep guard over `LockScreen.kt` — cannot work: the KDoc on that file + * *explains* the rule, and therefore contains the very words it forbids. That is + * `GUARDS.md` §2, "a source-grep guard must tell code from the comment about + * code", met head-on. Checking values instead of text sidesteps it entirely. + */ +internal object LockCopy { + + const val TITLE = "Enter your PIN" + const val FIELD_LABEL = "PIN" + const val SHOW = "Show PIN" + const val HIDE = "Hide PIN" + const val UNLOCK = "Unlock" + const val USE_BIOMETRIC = "Use fingerprint" + const val FORGOT = "Forgot your PIN?" + const val WRONG = "That is not the PIN." + + /** + * Deliberately says nothing about what is stored — only that this device can + * no longer check the PIN, and what the way out is. + */ + const val KEY_UNAVAILABLE = + "This device can no longer check your PIN. What you have recorded is still " + + "here, but the app cannot open it. Starting over is the only way back in." + + fun tryAgainIn(formatted: String) = "Try again in $formatted." + + /** Everything above, for the test that checks the whole surface at once. */ + val all: List = listOf( + TITLE, FIELD_LABEL, SHOW, HIDE, UNLOCK, USE_BIOMETRIC, FORGOT, WRONG, + KEY_UNAVAILABLE, tryAgainIn("1:00"), + ) +} diff --git a/app/src/main/kotlin/dev/privacyllc/period/feature/lock/LockEraseViewModel.kt b/app/src/main/kotlin/dev/privacyllc/period/feature/lock/LockEraseViewModel.kt new file mode 100644 index 0000000..35358b7 --- /dev/null +++ b/app/src/main/kotlin/dev/privacyllc/period/feature/lock/LockEraseViewModel.kt @@ -0,0 +1,86 @@ +package dev.privacyllc.period.feature.lock + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import dev.privacyllc.period.core.data.CycleRepository +import dev.privacyllc.period.core.datastore.UserPreferencesRepository +import dev.privacyllc.period.core.security.AppLockRepository +import kotlinx.coroutines.CoroutineExceptionHandler +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import javax.inject.Inject + +enum class EraseState { IDLE, ERASING, DONE, FAILED } + +/** + * The only way past a forgotten PIN, and it is destruction rather than recovery. + * + * The owner decided there is no recovery path: a backdoor into this app's lock + * would be used by exactly the person the lock exists to stop. That decision + * makes something else mandatory — a forgotten PIN must not leave an installed + * app that can never be opened again. So this exists, and it grants access to + * nothing. + * + * It also grants an attacker nothing they did not already have. Anyone holding + * the phone can clear this app's storage from Android's own settings, or + * uninstall it, and with `allowBackup="false"` and every extraction domain + * excluded there is no restore either way. What this adds is honesty: the user + * finds out from us, in plain words, rather than by discovering their history is + * gone. + * + * ## The order matters, and the second step is the one that gets forgotten + * + * 1. the health records + * 2. **the Keystore key and the stored verifier** + * 3. the biometric preference + * + * Skip step 2 and the user has erased everything and is *still locked out* — a + * working lock over an empty database, with no PIN that opens it and nothing + * left to protect. That is the worst outcome this feature can produce, so a test + * mutates that call out and requires exactly one red. + * + * ## What it deliberately does not touch + * + * `resetToDefaults()` is not called. `PrivacyViewModel`'s KDoc argues that + * somebody exercising a privacy control has not asked to have their notification + * privacy reset to a default they never chose, and that applies here with more + * force, not less. `ReminderScheduler.cancel()` is not called either, for the + * reason recorded there: with no history the worker already decides to say + * nothing, and cancelling would leave reminders silently off until the user next + * touched a setting. Onboarding is not reset — `PeriodRoot`'s KDoc settles that + * somebody who deletes their data has not asked to be onboarded again. + */ +@HiltViewModel +class LockEraseViewModel @Inject constructor( + private val cycles: CycleRepository, + private val lock: AppLockRepository, + private val preferences: UserPreferencesRepository, +) : ViewModel() { + + private val _state = MutableStateFlow(EraseState.IDLE) + val state: StateFlow = _state.asStateFlow() + + private val handler = CoroutineExceptionHandler { _, _ -> + _state.value = EraseState.FAILED + } + + fun eraseEverything() { + if (_state.value == EraseState.ERASING) return + _state.value = EraseState.ERASING + viewModelScope.launch(handler) { + cycles.deleteAllHealthData() + // Destroys the Keystore key as well as the stored verifier. Without + // this line the erase leaves a lock nobody can open. + lock.clearLock() + preferences.setBiometricLockEnabled(false) + _state.value = EraseState.DONE + } + } + + fun acknowledge() { + _state.value = EraseState.IDLE + } +} diff --git a/app/src/main/kotlin/dev/privacyllc/period/feature/lock/LockScreen.kt b/app/src/main/kotlin/dev/privacyllc/period/feature/lock/LockScreen.kt new file mode 100644 index 0000000..ea46025 --- /dev/null +++ b/app/src/main/kotlin/dev/privacyllc/period/feature/lock/LockScreen.kt @@ -0,0 +1,278 @@ +package dev.privacyllc.period.feature.lock + +import android.content.res.Configuration +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.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +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.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.semantics.LiveRegionMode +import androidx.compose.ui.semantics.liveRegion +import androidx.compose.ui.semantics.password +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.activity.compose.LocalActivity +import androidx.fragment.app.FragmentActivity +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import dev.privacyllc.period.designsystem.PeriodTheme +import dev.privacyllc.period.lock.AppLockViewModel +import dev.privacyllc.period.lock.rememberBiometricUnlock +import dev.privacyllc.period.lock.LockScreenState +import kotlinx.coroutines.delay + +/** + * What somebody sees before they have proved who they are. + * + * ## It says almost nothing, on purpose + * + * This is the one screen in the app that is **semi-public**: it is what appears + * the instant the owner opens the app in front of somebody else, and what + * anybody who picks the phone up will see. So it carries none of + * `NotificationCopy.SENSITIVE_WORDS` — no "period", no "cycle", no "fertility" — + * and there is a unit test asserting exactly that, because this is copy that + * gets edited later by somebody who has forgotten why it is bare. + * + * There is also no illustration. `docs/design/README.md` warns that a padlock + * motif "would read as a security product rather than a calm one", and the + * lock screen is where that temptation is strongest. + * + * ## Why an ordinary text field and not a custom keypad + * + * A hand-rolled keypad would let the PIN live in a `CharArray` that can be + * zeroed, where a `TextField` hands back an immutable `String` that cannot. That + * is a real difference and it is the wrong trade here. The zeroing is + * best-effort at most — the JVM may have copied the buffer during GC — while the + * costs are concrete: TalkBack does not read a custom grid the way it reads a + * password field, and a 3×4 grid does not fit at font scale 2.0, which this + * project has already shipped a defect against once. The documented adversary is + * somebody holding the phone, not somebody dumping its heap; that person would + * read the unencrypted database instead. + */ +@Composable +fun LockScreen(viewModel: AppLockViewModel) { + val state by viewModel.screen.collectAsStateWithLifecycle() + val biometricAllowed by viewModel.biometricAllowed.collectAsStateWithLifecycle() + var erasing by remember { mutableStateOf(false) } + + if (erasing) { + ForgotPinScreen(onCancel = { erasing = false }) + return + } + + // Offered on a tap, never fired automatically. An auto-prompt on every + // appearance fights the user who wants the PIN, and on a device that + // returns an immediate error it becomes a loop with nothing to press. + val biometric = rememberBiometricUnlock( + activity = LocalActivity.current as? FragmentActivity, + onBeginAuth = viewModel::beginBiometricAuth, + onEndAuth = viewModel::endBiometricAuth, + onUnlocked = viewModel::unlockFromBiometric, + onUnavailable = viewModel::disableBiometric, + ) + + LockScreenContent( + state = state, + biometricAllowed = biometricAllowed && biometric.available, + onSubmit = viewModel::submit, + onTick = viewModel::refreshLockout, + onForgot = { erasing = true }, + onUseBiometric = biometric.prompt, + ) +} + +@Composable +internal fun LockScreenContent( + state: LockScreenState, + biometricAllowed: Boolean, + onSubmit: (CharArray) -> Unit, + onTick: () -> Unit, + onForgot: () -> Unit, + onUseBiometric: () -> Unit = {}, +) { + var pin by remember { mutableStateOf("") } + // Reset on every entry, never remembered. A "show PIN" left on from last + // time would reveal the next one to whoever is standing there. + var revealed by remember { mutableStateOf(false) } + + val lockedOut = state.waitMillis > 0L + + // Ticks only while a lockout is running, and only once a second — a + // per-frame countdown would make TalkBack announce continuously. + LaunchedEffect(lockedOut) { + while (lockedOut) { + delay(1_000) + onTick() + } + } + + Surface(modifier = Modifier.fillMaxSize()) { + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(24.dp), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + text = LockCopy.TITLE, + style = MaterialTheme.typography.headlineMedium, + textAlign = TextAlign.Center, + ) + + Spacer(Modifier.height(24.dp)) + + OutlinedTextField( + value = pin, + onValueChange = { entered -> pin = entered.filter(Char::isDigit).take(MAX_PIN) }, + singleLine = true, + enabled = !state.checking && !lockedOut && !state.keyUnavailable, + label = { Text(LockCopy.FIELD_LABEL) }, + visualTransformation = + if (revealed) VisualTransformation.None else PasswordVisualTransformation(), + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.NumberPassword, + imeAction = ImeAction.Done, + ), + keyboardActions = KeyboardActions(onDone = { submit(pin, onSubmit) { pin = "" } }), + // Tells TalkBack this is a password so it is not read aloud, and + // keeps it out of autofill. + modifier = Modifier + .fillMaxWidth() + .semantics { password() }, + ) + + TextButton(onClick = { revealed = !revealed }) { + Text(if (revealed) LockCopy.HIDE else LockCopy.SHOW) + } + + Spacer(Modifier.height(8.dp)) + + Message(state = state) + + Spacer(Modifier.height(16.dp)) + + Button( + onClick = { submit(pin, onSubmit) { pin = "" } }, + enabled = pin.length >= MIN_PIN && !state.checking && !lockedOut && !state.keyUnavailable, + modifier = Modifier.fillMaxWidth(), + ) { + Text(LockCopy.UNLOCK) + } + + if (biometricAllowed && !lockedOut && !state.keyUnavailable) { + TextButton(onClick = onUseBiometric, modifier = Modifier.fillMaxWidth()) { + Text(LockCopy.USE_BIOMETRIC) + } + } + + Spacer(Modifier.height(24.dp)) + + TextButton(onClick = onForgot) { + Text(LockCopy.FORGOT, style = MaterialTheme.typography.bodyMedium) + } + } + } +} + +private inline fun submit(pin: String, onSubmit: (CharArray) -> Unit, clear: () -> Unit) { + if (pin.length < MIN_PIN) return + onSubmit(pin.toCharArray()) + clear() +} + +/** + * The one place the screen says anything, and it never says how many attempts + * are left. + * + * A remaining-attempts count tells an attacker how much room they have and + * tells the owner they are about to lose everything — and since nothing is ever + * erased automatically, it would also be a lie. + */ +@Composable +private fun Message(state: LockScreenState) { + val text = when { + state.keyUnavailable -> LockCopy.KEY_UNAVAILABLE + state.waitMillis > 0L -> LockCopy.tryAgainIn(formatWait(state.waitMillis)) + state.wrong -> LockCopy.WRONG + else -> null + } ?: return + + Text( + text = text, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.error, + textAlign = TextAlign.Center, + // Announced when it changes, rather than on every countdown tick. + modifier = Modifier + .fillMaxWidth() + .semantics { liveRegion = LiveRegionMode.Polite }, + ) +} + +internal fun formatWait(millis: Long): String { + val totalSeconds = ((millis + 999) / 1000).toInt() + val minutes = totalSeconds / 60 + val seconds = totalSeconds % 60 + return if (minutes > 0) "$minutes:${seconds.toString().padStart(2, '0')}" else "${seconds}s" +} + +private const val MIN_PIN = 4 +private const val MAX_PIN = 12 + +@Preview(name = "Lock · light", showBackground = true) +@Preview(name = "Lock · dark", showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES) +@Preview(name = "Lock · font 2.0", showBackground = true, fontScale = 2.0f) +@Composable +private fun LockPreview() { + PeriodTheme { + LockScreenContent( + state = LockScreenState(wrong = true), + biometricAllowed = true, + onSubmit = {}, + onTick = {}, + onForgot = {}, + ) + } +} + +@Preview(name = "Lock · waiting", showBackground = true) +@Preview(name = "Lock · waiting, font 2.0", showBackground = true, fontScale = 2.0f) +@Composable +private fun LockWaitingPreview() { + PeriodTheme { + LockScreenContent( + state = LockScreenState(waitMillis = 125_000L), + biometricAllowed = false, + onSubmit = {}, + onTick = {}, + onForgot = {}, + ) + } +} diff --git a/app/src/main/kotlin/dev/privacyllc/period/feature/lock/LockSettingsScreen.kt b/app/src/main/kotlin/dev/privacyllc/period/feature/lock/LockSettingsScreen.kt new file mode 100644 index 0000000..c43f444 --- /dev/null +++ b/app/src/main/kotlin/dev/privacyllc/period/feature/lock/LockSettingsScreen.kt @@ -0,0 +1,282 @@ +package dev.privacyllc.period.feature.lock + +import android.content.res.Configuration +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Surface +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.semantics.LiveRegionMode +import androidx.compose.ui.semantics.liveRegion +import androidx.compose.ui.semantics.password +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import dev.privacyllc.period.designsystem.PeriodTheme + +private enum class Mode { OVERVIEW, SET_FIRST, CONFIRM_TO_REMOVE, CONFIRM_TO_CHANGE, SET_REPLACEMENT } + +/** + * §36's App lock, one level under Privacy & Security. + * + * Turning the lock **off** asks for the current PIN, and so does changing it. + * Without that, the lock protects nothing: whoever picked up the unlocked phone + * would simply switch it off in Settings — which is the adversary this feature + * exists for. Setting the first PIN needs no authentication because there is + * nothing yet to authenticate against. + */ +@Composable +fun LockSettingsScreen(viewModel: LockSettingsViewModel = hiltViewModel()) { + val state by viewModel.state.collectAsStateWithLifecycle() + var mode by remember { mutableStateOf(Mode.OVERVIEW) } + + when (mode) { + Mode.OVERVIEW -> Overview( + state = state, + onSetPin = { mode = Mode.SET_FIRST }, + onChangePin = { mode = Mode.CONFIRM_TO_CHANGE }, + onRemovePin = { mode = Mode.CONFIRM_TO_REMOVE }, + onBiometric = viewModel::setBiometricEnabled, + onDismissMessage = viewModel::clearMessage, + ) + + Mode.SET_FIRST, Mode.SET_REPLACEMENT -> PinSetupScreen( + busy = state.busy, + failed = state.message == LockSettings.Message.COULD_NOT_SET, + onCancel = { mode = Mode.OVERVIEW; viewModel.clearMessage() }, + onConfirmed = { pin -> viewModel.setPin(pin); mode = Mode.OVERVIEW }, + ) + + Mode.CONFIRM_TO_REMOVE -> ConfirmPin( + title = "Enter your PIN to turn the lock off", + busy = state.busy, + wrong = state.message == LockSettings.Message.WRONG_PIN, + onCancel = { mode = Mode.OVERVIEW; viewModel.clearMessage() }, + onSubmit = { pin -> viewModel.removePin(pin); mode = Mode.OVERVIEW }, + ) + + Mode.CONFIRM_TO_CHANGE -> ConfirmPin( + title = "Enter your current PIN", + busy = state.busy, + wrong = state.message == LockSettings.Message.WRONG_PIN, + onCancel = { mode = Mode.OVERVIEW; viewModel.clearMessage() }, + onSubmit = { pin -> + viewModel.verifyCurrent(pin) { } + mode = Mode.SET_REPLACEMENT + }, + ) + } +} + +@Composable +private fun Overview( + state: LockSettings, + onSetPin: () -> Unit, + onChangePin: () -> Unit, + onRemovePin: () -> Unit, + onBiometric: (Boolean) -> Unit, + onDismissMessage: () -> Unit, +) { + Surface(modifier = Modifier.fillMaxSize()) { + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(vertical = 16.dp), + ) { + Text( + "App lock", + style = MaterialTheme.typography.headlineSmall, + modifier = Modifier.padding(horizontal = 24.dp), + ) + + Spacer(Modifier.height(8.dp)) + + Text( + if (state.hasPin) { + "The app asks for your PIN before it opens. While the lock is on, it is " + + "hidden in the task switcher and screenshots are blocked." + } else { + "Ask for a PIN before the app opens. There is no way to reset a forgotten " + + "PIN, so you will be asked to confirm you understand that first." + }, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 24.dp), + ) + + Spacer(Modifier.height(16.dp)) + + if (!state.hasPin) { + Row(modifier = Modifier.padding(horizontal = 24.dp)) { + Button(onClick = onSetPin, enabled = !state.busy) { Text("Set a PIN") } + } + } else { + LockRow("Change PIN", "You will be asked for the current one first", onChangePin) + LockRow("Turn off app lock", "The app will open without a PIN", onRemovePin) + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Column(modifier = Modifier.fillMaxWidth(0.75f)) { + Text("Unlock with fingerprint", style = MaterialTheme.typography.bodyLarge) + Text( + // The thing a person cannot otherwise know, said plainly. + "Anyone whose fingerprint or face is set up on this phone will be " + + "able to open the app.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Switch(checked = state.biometricEnabled, onCheckedChange = onBiometric) + } + } + + state.message?.let { message -> + Spacer(Modifier.height(16.dp)) + Text( + text = when (message) { + LockSettings.Message.PIN_SET -> "App lock is on." + LockSettings.Message.PIN_REMOVED -> "App lock is off." + LockSettings.Message.WRONG_PIN -> "That is not the PIN." + LockSettings.Message.COULD_NOT_SET -> + "This device could not store a PIN, so the lock has not been turned on." + }, + style = MaterialTheme.typography.bodyMedium, + color = if (message == LockSettings.Message.WRONG_PIN) { + MaterialTheme.colorScheme.error + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + modifier = Modifier + .padding(horizontal = 24.dp) + .semantics { liveRegion = LiveRegionMode.Polite }, + ) + TextButton( + onClick = onDismissMessage, + modifier = Modifier.padding(horizontal = 16.dp), + ) { Text("Done") } + } + } + } +} + +@Composable +private fun LockRow(title: String, subtitle: String, onClick: () -> Unit) { + Column( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .padding(horizontal = 24.dp, vertical = 12.dp), + ) { + Text(title, style = MaterialTheme.typography.bodyLarge) + Text( + subtitle, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } +} + +@Composable +private fun ConfirmPin( + title: String, + busy: Boolean, + wrong: Boolean, + onCancel: () -> Unit, + onSubmit: (CharArray) -> Unit, +) { + var pin by remember { mutableStateOf("") } + Surface(modifier = Modifier.fillMaxSize()) { + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(24.dp), + verticalArrangement = Arrangement.Center, + ) { + Text(title, style = MaterialTheme.typography.headlineSmall) + Spacer(Modifier.height(16.dp)) + OutlinedTextField( + value = pin, + onValueChange = { pin = it.filter(Char::isDigit).take(12) }, + singleLine = true, + enabled = !busy, + label = { Text("PIN") }, + visualTransformation = PasswordVisualTransformation(), + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.NumberPassword, + imeAction = ImeAction.Done, + ), + modifier = Modifier + .fillMaxWidth() + .semantics { password() }, + ) + if (wrong) { + Spacer(Modifier.height(8.dp)) + Text( + "That is not the PIN.", + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.semantics { liveRegion = LiveRegionMode.Polite }, + ) + } + Spacer(Modifier.height(20.dp)) + Button( + onClick = { onSubmit(pin.toCharArray()); pin = "" }, + enabled = !busy && pin.length >= 4, + modifier = Modifier.fillMaxWidth(), + ) { Text("Continue") } + TextButton(onClick = onCancel, modifier = Modifier.fillMaxWidth()) { Text("Cancel") } + } + } +} + +@Preview(name = "App lock · off", showBackground = true) +@Preview(name = "App lock · off, dark", showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES) +@Preview(name = "App lock · off, font 2.0", showBackground = true, heightDp = 1000, fontScale = 2.0f) +@Composable +private fun LockSettingsOffPreview() { + PeriodTheme { + Overview(LockSettings(hasPin = false), {}, {}, {}, {}, {}) + } +} + +@Preview(name = "App lock · on", showBackground = true) +@Preview(name = "App lock · on, font 2.0", showBackground = true, heightDp = 1200, fontScale = 2.0f) +@Composable +private fun LockSettingsOnPreview() { + PeriodTheme { + Overview(LockSettings(hasPin = true, biometricEnabled = true), {}, {}, {}, {}, {}) + } +} diff --git a/app/src/main/kotlin/dev/privacyllc/period/feature/lock/LockSettingsViewModel.kt b/app/src/main/kotlin/dev/privacyllc/period/feature/lock/LockSettingsViewModel.kt new file mode 100644 index 0000000..171ccbc --- /dev/null +++ b/app/src/main/kotlin/dev/privacyllc/period/feature/lock/LockSettingsViewModel.kt @@ -0,0 +1,128 @@ +package dev.privacyllc.period.feature.lock + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import dev.privacyllc.period.core.datastore.UserPreferencesRepository +import dev.privacyllc.period.core.security.AppLockRepository +import dev.privacyllc.period.core.security.UnlockResult +import dev.privacyllc.period.lock.AppLockController +import kotlinx.coroutines.CoroutineExceptionHandler +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import javax.inject.Inject + +/** What the App lock settings screen is showing. */ +data class LockSettings( + val hasPin: Boolean = false, + val biometricEnabled: Boolean = false, + val busy: Boolean = false, + val message: Message? = null, +) { + enum class Message { PIN_SET, PIN_REMOVED, WRONG_PIN, COULD_NOT_SET } +} + +/** + * Turning the lock on and off, and changing the PIN. + * + * ## Removing the lock asks for the PIN first + * + * Otherwise the lock protects nothing: anybody who picks up an unlocked phone + * could open Settings and switch it off, which is precisely the adversary + * `SECURITY.md` names first. Setting a PIN for the first time needs no + * authentication, because there is nothing yet to authenticate against. + */ +@HiltViewModel +class LockSettingsViewModel @Inject constructor( + private val lock: AppLockRepository, + private val preferences: UserPreferencesRepository, + private val controller: AppLockController, +) : ViewModel() { + + private val _busy = MutableStateFlow(false) + private val _message = MutableStateFlow(null) + + val state: StateFlow = combine( + lock.hasPin, + preferences.preferences.map { it.biometricLockEnabled }, + _busy, + _message, + ) { hasPin, biometric, busy, message -> + LockSettings( + hasPin = hasPin, + // Meaningless without a PIN to stand in for, so never shown as on + // without one. The stored flag is left alone rather than corrected, + // so turning the lock back on restores the choice the user made. + biometricEnabled = biometric && hasPin, + busy = busy, + message = message, + ) + }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), LockSettings()) + + private val handler = CoroutineExceptionHandler { _, _ -> + _busy.value = false + _message.value = LockSettings.Message.COULD_NOT_SET + } + + /** First PIN, or a replacement once [verifyCurrent] has passed. */ + fun setPin(pin: CharArray) { + if (_busy.value) return + _busy.value = true + viewModelScope.launch(handler) { + val ok = lock.setPin(pin) + pin.fill(' ') + // Stay unlocked in the session that just set the PIN. Without this + // the gate flips shut the instant `hasPin` turns true, and the user + // is asked for a PIN one second after choosing it — which reads as + // the app not having understood, and is the first thing they see of + // a feature they have just been warned is unrecoverable. + if (ok) controller.unlock() + _busy.value = false + _message.value = + if (ok) LockSettings.Message.PIN_SET else LockSettings.Message.COULD_NOT_SET + } + } + + /** + * Check the current PIN, then run [onVerified]. + * + * Used by both "turn the lock off" and "change PIN". A wrong answer here + * goes through the same backoff as the lock screen, because otherwise + * Settings would be an unlimited guessing oracle for the same secret. + */ + fun verifyCurrent(pin: CharArray, onVerified: suspend () -> Unit) { + if (_busy.value) return + _busy.value = true + viewModelScope.launch(handler) { + val outcome = lock.check(pin) + pin.fill(' ') + if (outcome is UnlockResult.Unlocked) { + onVerified() + _busy.value = false + } else { + _busy.value = false + _message.value = LockSettings.Message.WRONG_PIN + } + } + } + + fun removePin(current: CharArray) = verifyCurrent(current) { + lock.clearLock() + preferences.setBiometricLockEnabled(false) + _message.value = LockSettings.Message.PIN_REMOVED + } + + fun setBiometricEnabled(enabled: Boolean) { + viewModelScope.launch(handler) { preferences.setBiometricLockEnabled(enabled) } + } + + fun clearMessage() { + _message.value = null + } +} diff --git a/app/src/main/kotlin/dev/privacyllc/period/feature/lock/PinSetupScreen.kt b/app/src/main/kotlin/dev/privacyllc/period/feature/lock/PinSetupScreen.kt new file mode 100644 index 0000000..d33dbd9 --- /dev/null +++ b/app/src/main/kotlin/dev/privacyllc/period/feature/lock/PinSetupScreen.kt @@ -0,0 +1,236 @@ +package dev.privacyllc.period.feature.lock + +import android.content.res.Configuration +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.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +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.semantics.LiveRegionMode +import androidx.compose.ui.semantics.liveRegion +import androidx.compose.ui.semantics.password +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import dev.privacyllc.period.designsystem.PeriodTheme + +/** + * Consent first, then the PIN. + * + * ## Why this is a screen and not a checkbox + * + * "There is no way to reset this" is only fair if it is said **before** the + * decision, in words, on its own. A checkbox under a PIN field is a thing people + * tick; a sentence they have to read and press past is a thing they know. The + * owner ruled out any recovery path, and that decision is only defensible if the + * person setting the PIN was told plainly at the time rather than on the day + * they forget. + * + * ## Why it also says what the lock does *not* do + * + * The second half is the part a product would normally leave out. This lock + * stops the app opening; it does not encrypt anything. The records sit in + * app-private storage, and somebody who unlocks the phone and connects it to a + * computer, or who has rooted it, is not stopped by a PIN. `SECURITY.md` already + * lists both as out of scope, and a lock screen that implied otherwise would be + * this product's one dishonest moment. + */ +@Composable +internal fun PinSetupScreen( + busy: Boolean, + failed: Boolean, + onCancel: () -> Unit, + onConfirmed: (CharArray) -> Unit, +) { + var consented by remember { mutableStateOf(false) } + if (!consented) { + Consent(onCancel = onCancel, onAccept = { consented = true }) + } else { + PinEntry(busy = busy, failed = failed, onCancel = onCancel, onConfirmed = onConfirmed) + } +} + +@Composable +private fun Consent(onCancel: () -> Unit, onAccept: () -> Unit) { + Surface(modifier = Modifier.fillMaxSize()) { + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(24.dp), + verticalArrangement = Arrangement.Center, + ) { + Text("Before you set a PIN", style = MaterialTheme.typography.headlineMedium) + + Spacer(Modifier.height(20.dp)) + + Text("There is no way to reset it", style = MaterialTheme.typography.titleMedium) + Spacer(Modifier.height(6.dp)) + Text( + "Nobody can reset this PIN — not us, not a support email, not a security " + + "question. If you forget it, the only way back into the app is to erase " + + "everything you have recorded and start again.", + style = MaterialTheme.typography.bodyLarge, + ) + + Spacer(Modifier.height(20.dp)) + + Text("What the lock does, and what it does not", style = MaterialTheme.typography.titleMedium) + Spacer(Modifier.height(6.dp)) + Text( + "It stops the app opening without your PIN. That is the thing it is for: " + + "somebody picking up your unlocked phone.", + style = MaterialTheme.typography.bodyLarge, + ) + Spacer(Modifier.height(6.dp)) + Text( + "It does not encrypt what you have recorded. Somebody who unlocks this " + + "phone and connects it to a computer, or who has rooted it, is not " + + "stopped by this PIN. We would rather tell you that than let a padlock " + + "imply otherwise.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + Spacer(Modifier.height(20.dp)) + + Text( + "While the lock is on, the app is hidden in the task switcher and " + + "screenshots are blocked.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + Spacer(Modifier.height(28.dp)) + + Button(onClick = onAccept, modifier = Modifier.fillMaxWidth()) { + Text("I understand — choose a PIN") + } + TextButton(onClick = onCancel, modifier = Modifier.fillMaxWidth()) { + Text("Not now") + } + } + } +} + +@Composable +private fun PinEntry( + busy: Boolean, + failed: Boolean, + onCancel: () -> Unit, + onConfirmed: (CharArray) -> Unit, +) { + var first by remember { mutableStateOf("") } + var second by remember { mutableStateOf("") } + var mismatch by remember { mutableStateOf(false) } + + Surface(modifier = Modifier.fillMaxSize()) { + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(24.dp), + verticalArrangement = Arrangement.Center, + ) { + Text("Choose a PIN", style = MaterialTheme.typography.headlineMedium) + Spacer(Modifier.height(8.dp)) + Text( + "Between $MIN_PIN and $MAX_PIN digits.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + Spacer(Modifier.height(20.dp)) + + PinField(value = first, label = "PIN", enabled = !busy) { + first = it; mismatch = false + } + Spacer(Modifier.height(12.dp)) + PinField(value = second, label = "Repeat PIN", enabled = !busy) { + second = it; mismatch = false + } + + if (mismatch || failed) { + Spacer(Modifier.height(8.dp)) + Text( + if (mismatch) { + "Those do not match." + } else { + "This device could not store a PIN, so the lock has not been turned on." + }, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.error, + modifier = Modifier.semantics { liveRegion = LiveRegionMode.Polite }, + ) + } + + Spacer(Modifier.height(20.dp)) + + Button( + onClick = { + if (first == second) onConfirmed(first.toCharArray()) else mismatch = true + }, + enabled = !busy && first.length >= MIN_PIN && second.isNotEmpty(), + modifier = Modifier.fillMaxWidth(), + ) { + Text("Turn on app lock") + } + TextButton(onClick = onCancel, modifier = Modifier.fillMaxWidth()) { Text("Cancel") } + } + } +} + +@Composable +private fun PinField(value: String, label: String, enabled: Boolean, onChange: (String) -> Unit) { + OutlinedTextField( + value = value, + onValueChange = { onChange(it.filter(Char::isDigit).take(MAX_PIN)) }, + singleLine = true, + enabled = enabled, + label = { Text(label) }, + visualTransformation = PasswordVisualTransformation(), + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.NumberPassword, + imeAction = ImeAction.Next, + ), + modifier = Modifier + .fillMaxWidth() + .semantics { password() }, + ) +} + +private const val MIN_PIN = 4 +private const val MAX_PIN = 12 + +@Preview(name = "Consent · light", showBackground = true, heightDp = 900) +@Preview( + name = "Consent · dark", + showBackground = true, + heightDp = 900, + uiMode = Configuration.UI_MODE_NIGHT_YES, +) +@Preview(name = "Consent · font 2.0", showBackground = true, heightDp = 1800, fontScale = 2.0f) +@Composable +private fun ConsentPreview() { + PeriodTheme { PinSetupScreen(busy = false, failed = false, onCancel = {}, onConfirmed = {}) } +} diff --git a/app/src/main/kotlin/dev/privacyllc/period/feature/settings/SettingsScreen.kt b/app/src/main/kotlin/dev/privacyllc/period/feature/settings/SettingsScreen.kt index ea15d24..9c83aac 100644 --- a/app/src/main/kotlin/dev/privacyllc/period/feature/settings/SettingsScreen.kt +++ b/app/src/main/kotlin/dev/privacyllc/period/feature/settings/SettingsScreen.kt @@ -55,6 +55,7 @@ import dev.privacyllc.period.designsystem.PeriodTheme @Composable fun SettingsScreen( onOpenNotifications: () -> Unit, + onOpenAppLock: () -> Unit, viewModel: PrivacyViewModel? = hiltViewModel(), ) { val deletion = viewModel?.deletion?.collectAsStateWithLifecycle()?.value ?: DeletionState.IDLE @@ -75,6 +76,11 @@ fun SettingsScreen( Spacer(Modifier.height(8.dp)) SectionHeader("Privacy & Security") + SettingsRow( + title = "App lock", + subtitle = "Ask for a PIN before the app opens", + onClick = onOpenAppLock, + ) SettingsRow( title = "Delete my data", subtitle = "Erase every period, spotting and prediction record", @@ -115,7 +121,8 @@ fun SettingsScreen( DeletionState.DONE -> ResultDialog( title = "Your data is deleted", body = "Every period, spotting and prediction record has been erased " + - "from this device. Your reminder settings are unchanged.", + "from this device. Your reminder settings and your app lock are " + + "unchanged.", onDismiss = { viewModel?.acknowledge() }, ) DeletionState.FAILED -> ResultDialog( @@ -146,7 +153,8 @@ private fun DeleteConfirmation(onConfirm: () -> Unit, onDismiss: () -> Unit) { text = { Text( "This erases every period, spotting and prediction record on this " + - "device. Your reminder and privacy settings are kept.\n\n" + + "device. Your reminder and privacy settings are kept, and so is your " + + "app lock.\n\n" + "This cannot be undone.", ) }, @@ -280,5 +288,5 @@ private fun StaticRow(title: String, value: String) { ) @Composable private fun PreviewSettingsRoot() = PeriodTheme { - SettingsScreen(onOpenNotifications = {}, viewModel = null) + SettingsScreen(onOpenNotifications = {}, onOpenAppLock = {}, viewModel = null) } diff --git a/app/src/main/kotlin/dev/privacyllc/period/lock/AppLockController.kt b/app/src/main/kotlin/dev/privacyllc/period/lock/AppLockController.kt new file mode 100644 index 0000000..2edb3d7 --- /dev/null +++ b/app/src/main/kotlin/dev/privacyllc/period/lock/AppLockController.kt @@ -0,0 +1,77 @@ +package dev.privacyllc.period.lock + +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Whether this session has been unlocked, and nothing else. + * + * ## Why this is not saved anywhere + * + * `rememberSaveable` looks like the obvious home for a boolean the UI cares + * about, and a `SavedStateHandle` looks like the tidy one. Both are wrong here + * in the same way: saved state survives process death, so an app killed in the + * background would be restored **already unlocked**, and the single most likely + * way to meet the lock screen — coming back to an app Android reclaimed hours + * ago — would be the one path that skipped it. + * + * Held in a `@Singleton` instead, so it lives exactly as long as the process + * and dies with it. A cold start is always locked. + */ +@Singleton +class AppLockController @Inject constructor() { + + private val _unlocked = MutableStateFlow(false) + val unlocked: StateFlow = _unlocked.asStateFlow() + + /** + * Set immediately before the biometric prompt appears and cleared in every + * one of its outcomes. + * + * The prompt is a system window, and on some devices it stops the activity. + * Without this flag the re-lock observer fires while the user is looking at + * the fingerprint dialog, so a successful scan returns to a locked screen — + * and tapping the fingerprint button again does the same thing, forever. + * + * `@Volatile` because it is written from the prompt callback and read by the + * lifecycle observer, which are not guaranteed to be the same thread. + */ + @Volatile + var authInProgress: Boolean = false + + private val _pendingNotificationAction = MutableStateFlow(null) + + /** + * Observed rather than read once, so an action arriving while the app is + * already open — `onNewIntent`, not `onCreate` — is delivered too. + */ + val pendingNotificationAction: StateFlow = _pendingNotificationAction.asStateFlow() + + fun unlock() { + _unlocked.value = true + } + + fun lock() { + _unlocked.value = false + } + + /** + * Park a notification action until the user has proved who they are. + * + * Tapping "Not yet" on a reminder writes a `NotYetObservation` into the + * health record. That tap is available to anybody holding the phone, from + * the lock screen of the phone itself — so with an app lock on, the write + * has to wait for the unlock, or the lock is decorative for the one action + * that modifies data. + */ + fun holdNotificationAction(action: String?) { + if (action != null) _pendingNotificationAction.value = action + } + + /** Returns the held action once, and forgets it. Null when there is none. */ + fun takeNotificationAction(): String? = + _pendingNotificationAction.value.also { _pendingNotificationAction.value = null } +} diff --git a/app/src/main/kotlin/dev/privacyllc/period/lock/AppLockGate.kt b/app/src/main/kotlin/dev/privacyllc/period/lock/AppLockGate.kt new file mode 100644 index 0000000..5eff71c --- /dev/null +++ b/app/src/main/kotlin/dev/privacyllc/period/lock/AppLockGate.kt @@ -0,0 +1,87 @@ +package dev.privacyllc.period.lock + +import androidx.activity.compose.LocalActivity +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +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 dev.privacyllc.period.feature.lock.LockScreen + +/** + * The lock, wrapped around everything. + * + * ## Why it wraps the composition rather than being a screen + * + * [content] is invoked **only** in the unlocked branch, and that is the whole + * design. Every one of Today, Calendar and Insights starts collecting from + * `CycleRepository` the moment it composes, so a lock implemented as a + * navigation destination — even the start destination — would have already + * built the ViewModels and read the history before the user proved anything. + * There is no lazy tab to hide behind. + * + * ## Re-locking, and the two guards that make it usable + * + * `ON_STOP`, not `ON_PAUSE`. Pause fires for the notification shade, quick + * settings, a permission dialog and losing focus in split-screen — re-locking on + * any of those makes the app unusable rather than secure. + * + * Even on stop there are two cases that must not re-lock: + * + * - **A configuration change.** Rotation, a theme switch and a font-scale change + * all stop and recreate the activity. This project actively tests at font + * scale 2.0, so without this guard the accessibility pass would fight the lock. + * - **The biometric prompt.** It is a system window, and on some devices it + * stops the activity underneath. Without this guard, a successful fingerprint + * returns to a locked screen and the next attempt does the same — a loop with + * no way out but the PIN. + * + * Deliberately no grace period. `SECURITY.md`'s threat model leads with + * "someone who picks up an unlocked phone", and a grace period is precisely the + * window that person uses. + */ +@Composable +fun AppLockGate( + viewModel: AppLockViewModel = hiltViewModel(), + content: @Composable () -> Unit, +) { + val state by viewModel.state.collectAsStateWithLifecycle() + val activity = LocalActivity.current + val lifecycleOwner = LocalLifecycleOwner.current + + DisposableEffect(lifecycleOwner, activity) { + val observer = LifecycleEventObserver { _, event -> + if (event != Lifecycle.Event.ON_STOP) return@LifecycleEventObserver + if (activity?.isChangingConfigurations == true) return@LifecycleEventObserver + if (viewModel.authInProgress) return@LifecycleEventObserver + viewModel.relock() + } + lifecycleOwner.lifecycle.addObserver(observer) + onDispose { lifecycleOwner.lifecycle.removeObserver(observer) } + } + + when (state) { + // One frame, drawing nothing. Same choice as RootState.Loading in + // PeriodApp.kt: a spinner that appears for 16ms reads as jank. Crucially + // it is not the unlocked branch, so no cycle data composes while the + // stores are still answering. + LockState.Unknown -> Unit + + LockState.Locked -> LockScreen(viewModel = viewModel) + + LockState.Unlocked -> { + // Drains any notification action parked while the lock was closed. + // Collected here rather than in the view model's own scope, so it + // can only fire while the unlocked branch is on screen. + val pending by viewModel.pendingNotificationAction.collectAsStateWithLifecycle() + LaunchedEffect(pending) { + if (pending != null) viewModel.deliverPendingNotificationAction() + } + content() + } + } +} diff --git a/app/src/main/kotlin/dev/privacyllc/period/lock/AppLockViewModel.kt b/app/src/main/kotlin/dev/privacyllc/period/lock/AppLockViewModel.kt new file mode 100644 index 0000000..1ecced0 Binary files /dev/null and b/app/src/main/kotlin/dev/privacyllc/period/lock/AppLockViewModel.kt differ diff --git a/app/src/main/kotlin/dev/privacyllc/period/lock/BiometricUnlock.kt b/app/src/main/kotlin/dev/privacyllc/period/lock/BiometricUnlock.kt new file mode 100644 index 0000000..dfc5250 --- /dev/null +++ b/app/src/main/kotlin/dev/privacyllc/period/lock/BiometricUnlock.kt @@ -0,0 +1,131 @@ +package dev.privacyllc.period.lock + +import androidx.biometric.BiometricManager +import androidx.biometric.BiometricPrompt +import androidx.compose.runtime.Composable +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.core.content.ContextCompat +import androidx.fragment.app.FragmentActivity + +/** What the biometric affordance should do, decided once per composition. */ +internal class BiometricUnlock( + val available: Boolean, + val prompt: () -> Unit, +) + +/** + * The fingerprint path, and the three decisions that shape it. + * + * ## `BIOMETRIC_STRONG` only — never `DEVICE_CREDENTIAL` + * + * Allowing the device credential would let the phone's own PIN open this app, + * and three separate reasons rule it out: + * + * 1. The owner ruled out recovery paths, and the device PIN is one. + * 2. In this app's threat model a partner very often knows the phone's PIN. + * Accepting it would silently make the app lock exactly as strong as the lock + * it sits behind — which is to say, no stronger at all. + * 3. `PromptInfo.Builder.build()` **throws** for `BIOMETRIC_STRONG or + * DEVICE_CREDENTIAL` on API 28 and 29, and for `DEVICE_CREDENTIAL` alone + * below 30. That is a crash, not an error callback, on a range this app + * supports. + * + * `BIOMETRIC_STRONG` alone behaves identically from API 26 to 36, with no + * version branching. + * + * ## Cancels are not failures + * + * `ERROR_NEGATIVE_BUTTON` is the user tapping "Use PIN" — an ordinary path with + * no error copy at all. `ERROR_USER_CANCELED` is a dismissal, and + * `ERROR_CANCELED` is the system pre-empting the prompt with no user action. + * None of the three may touch the PIN failure counter, or a stray dismissal + * would cost somebody a lockout they did not earn. + * + * ## Nothing here logs + * + * `app` is in `modulesSeeingHealthData`, and `printStackTrace` is in the + * forbidden list by name. This is exactly where the temptation is highest. + */ +@Composable +internal fun rememberBiometricUnlock( + activity: FragmentActivity?, + onBeginAuth: () -> Unit, + onEndAuth: () -> Unit, + onUnlocked: () -> Unit, + onUnavailable: () -> Unit, +): BiometricUnlock { + // Suppressed for the life of the process once the hardware reports a + // permanent lockout: only the device credential clears that, and this app's + // PIN is not it. Re-probed on the next cold start. + val permanentlyLockedOut = remember { mutableStateOf(false) } + + if (activity == null) return BiometricUnlock(available = false, prompt = {}) + + val status = BiometricManager.from(activity) + .canAuthenticate(BiometricManager.Authenticators.BIOMETRIC_STRONG) + + val available = !permanentlyLockedOut.value && when (status) { + BiometricManager.BIOMETRIC_SUCCESS -> true + // "Try it and be ready to fall back" — not "unavailable". The status is + // genuinely unknown on some devices, and hiding the button there would + // remove a working unlock. + BiometricManager.BIOMETRIC_STATUS_UNKNOWN -> true + else -> false + } + + val prompt = { + onBeginAuth() + val callback = object : BiometricPrompt.AuthenticationCallback() { + override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) { + onEndAuth() + onUnlocked() + } + + override fun onAuthenticationError(code: Int, message: CharSequence) { + onEndAuth() + when (code) { + // Ordinary exits. Say nothing, change nothing. + BiometricPrompt.ERROR_NEGATIVE_BUTTON, + BiometricPrompt.ERROR_USER_CANCELED, + BiometricPrompt.ERROR_CANCELED, + -> Unit + + // No fingerprint enrolled any more, or the hardware is gone. + // Turn the offer off so the user is not sent back to a + // button that cannot work. + BiometricPrompt.ERROR_NO_BIOMETRICS, + BiometricPrompt.ERROR_HW_NOT_PRESENT, + -> onUnavailable() + + // Cleared only by the device credential, which this app's + // PIN is not — so retrying produces the same error forever. + BiometricPrompt.ERROR_LOCKOUT_PERMANENT -> + permanentlyLockedOut.value = true + + // Transient: temporary lockout, hardware busy, a vendor + // string. Leave the setting alone; the PIN still works. + else -> Unit + } + } + + override fun onAuthenticationFailed() { + // A finger that did not match. The prompt stays up and handles + // its own retries; this must not touch the PIN counter. + } + } + + BiometricPrompt(activity, ContextCompat.getMainExecutor(activity), callback) + .authenticate( + BiometricPrompt.PromptInfo.Builder() + .setTitle("Unlock") + .setSubtitle("Use your fingerprint to open the app") + .setNegativeButtonText("Use PIN") + .setAllowedAuthenticators(BiometricManager.Authenticators.BIOMETRIC_STRONG) + .setConfirmationRequired(false) + .build(), + ) + } + + return BiometricUnlock(available = available, prompt = prompt) +} diff --git a/app/src/main/kotlin/dev/privacyllc/period/navigation/PeriodApp.kt b/app/src/main/kotlin/dev/privacyllc/period/navigation/PeriodApp.kt index a0d8425..40cf729 100644 --- a/app/src/main/kotlin/dev/privacyllc/period/navigation/PeriodApp.kt +++ b/app/src/main/kotlin/dev/privacyllc/period/navigation/PeriodApp.kt @@ -38,6 +38,7 @@ import dev.privacyllc.period.R import dev.privacyllc.period.designsystem.PeriodTheme import dev.privacyllc.period.feature.calendar.CalendarScreen import dev.privacyllc.period.feature.insights.InsightsScreen +import dev.privacyllc.period.feature.lock.LockSettingsScreen import dev.privacyllc.period.feature.onboarding.OnboardingScreen import dev.privacyllc.period.feature.settings.NotificationSettingsScreen import dev.privacyllc.period.feature.settings.SettingsScreen @@ -69,17 +70,13 @@ enum class PeriodDestination( * onboarded again, and somebody who abandoned onboarding halfway has. */ @Composable -fun PeriodRoot( - notificationAction: String? = null, - viewModel: RootViewModel = hiltViewModel(), -) { +fun PeriodRoot(viewModel: RootViewModel = hiltViewModel()) { val state by viewModel.state.collectAsStateWithLifecycle() - // Applied once per delivered intent. Keyed on the action so a rotation does - // not record a second "Not yet" the user never tapped. - androidx.compose.runtime.LaunchedEffect(notificationAction) { - viewModel.onNotificationAction(notificationAction) - } + // Notification actions no longer arrive here. They are parked in + // AppLockController and applied by AppLockGate once the session is + // unlocked, because tapping "Not yet" writes to the health record and that + // button is reachable from the phone's own lock screen by anybody. when (state) { RootState.Loading -> Unit // one frame; a spinner here flashes and reads as jank @@ -146,9 +143,11 @@ fun PeriodApp() { composable(PeriodDestination.SETTINGS.route) { SettingsScreen( onOpenNotifications = { navController.navigate(SETTINGS_NOTIFICATIONS) }, + onOpenAppLock = { navController.navigate(SETTINGS_LOCK) }, ) } composable(SETTINGS_NOTIFICATIONS) { NotificationSettingsScreen() } + composable(SETTINGS_LOCK) { LockSettingsScreen() } } } } @@ -192,3 +191,6 @@ private fun PlaceholderPreview() { /** Reminder settings, a child of the Settings tab rather than a fifth tab. */ private const val SETTINGS_NOTIFICATIONS = "settings/notifications" + +/** §36 puts the app lock above Delete My Data: protect first, destroy second. */ +private const val SETTINGS_LOCK = "settings/lock" diff --git a/app/src/main/kotlin/dev/privacyllc/period/navigation/RootViewModel.kt b/app/src/main/kotlin/dev/privacyllc/period/navigation/RootViewModel.kt index a5c188d..8f93c64 100644 --- a/app/src/main/kotlin/dev/privacyllc/period/navigation/RootViewModel.kt +++ b/app/src/main/kotlin/dev/privacyllc/period/navigation/RootViewModel.kt @@ -18,22 +18,14 @@ enum class RootState { Loading, Onboarding, Ready } @HiltViewModel class RootViewModel @Inject constructor( preferences: UserPreferencesRepository, - private val notificationActions: dev.privacyllc.period.notifications.NotificationActionHandler, ) : ViewModel() { - /** - * Apply an action tapped on a notification. - * - * Handled once per intent by the caller. Doing it here rather than in the - * activity keeps it off the main thread and, more importantly, routes it - * through the same repository call the in-app button uses — two paths into - * one piece of state is how a lock-screen "Not yet" and an in-app "Not yet" - * come to mean slightly different things. - */ - fun onNotificationAction(action: String?) { - if (action == null) return - viewModelScope.launch { notificationActions.handle(action) } - } + // Notification actions used to be applied here. They moved to + // AppLockViewModel, which is the only place that knows whether the session + // has been unlocked — the write they perform must not happen for somebody + // who tapped a reminder button and never proved who they were. The + // repository call itself is unchanged and still shared with the in-app + // button, which is what keeps the two meaning the same thing. /** * Set when onboarding finishes, so the switch happens immediately rather diff --git a/app/src/test/kotlin/dev/privacyllc/period/lock/AppLockControllerTest.kt b/app/src/test/kotlin/dev/privacyllc/period/lock/AppLockControllerTest.kt new file mode 100644 index 0000000..817c338 --- /dev/null +++ b/app/src/test/kotlin/dev/privacyllc/period/lock/AppLockControllerTest.kt @@ -0,0 +1,68 @@ +package dev.privacyllc.period.lock + +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.flow.first +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * The session flag, and the parked notification action. + * + * Pure Kotlin — no Robolectric, no Android. The controller holds no Android + * types precisely so this can be true. + */ +class AppLockControllerTest { + + @Test fun `a new process starts locked`() = runBlocking { + assertFalse( + "a cold start must never begin unlocked — that is the whole reason " + + "this is not saved state", + AppLockController().unlocked.first(), + ) + } + + @Test fun `unlock and lock move the flag`() = runBlocking { + val controller = AppLockController() + controller.unlock() + assertTrue(controller.unlocked.first()) + controller.lock() + assertFalse(controller.unlocked.first()) + } + + /** + * Tapping "Not yet" on a reminder writes to the health record, and that + * button sits on the phone's own lock screen where anybody can reach it. The + * action is therefore parked, not applied, until somebody authenticates. + */ + @Test fun `a notification action is held rather than applied`() = runBlocking { + val controller = AppLockController() + controller.holdNotificationAction("not_yet") + + assertEquals("not_yet", controller.pendingNotificationAction.first()) + assertEquals("not_yet", controller.takeNotificationAction()) + } + + @Test fun `taking the action consumes it, so it cannot be applied twice`() { + val controller = AppLockController() + controller.holdNotificationAction("not_yet") + + assertEquals("not_yet", controller.takeNotificationAction()) + assertNull("a second take must find nothing", controller.takeNotificationAction()) + } + + @Test fun `holding null does not clear an action already waiting`() { + val controller = AppLockController() + controller.holdNotificationAction("started") + // Every launch delivers an intent; most carry no action. That must not + // discard one that is genuinely waiting. + controller.holdNotificationAction(null) + assertEquals("started", controller.takeNotificationAction()) + } + + @Test fun `the auth-in-progress flag defaults to false`() { + assertFalse(AppLockController().authInProgress) + } +} diff --git a/app/src/test/kotlin/dev/privacyllc/period/lock/LockCopyTest.kt b/app/src/test/kotlin/dev/privacyllc/period/lock/LockCopyTest.kt new file mode 100644 index 0000000..22b676b --- /dev/null +++ b/app/src/test/kotlin/dev/privacyllc/period/lock/LockCopyTest.kt @@ -0,0 +1,55 @@ +package dev.privacyllc.period.lock + +import dev.privacyllc.period.core.notifications.NotificationCopy +import dev.privacyllc.period.feature.lock.LockCopy +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * The lock screen is semi-public, so it may not disclose what the app is for. + * + * This is the same rule the notification copy already lives under, checked + * against the same list, and it matters here for the same reason: the text is + * visible to somebody who has not authenticated. A future edit that made the + * empty screen friendlier — "Unlock to see your cycle" — would defeat the lock + * for anybody reading over a shoulder, and would look like an improvement in + * review. + * + * Values rather than source text, deliberately. Grepping `LockScreen.kt` cannot + * work: its KDoc explains this rule and therefore contains every word it + * forbids. `GUARDS.md` §2. + */ +class LockCopyTest { + + @Test fun `no lock screen string discloses what this app records`() { + val offenders = LockCopy.all.flatMap { line -> + NotificationCopy.SENSITIVE_WORDS + .filter { word -> line.contains(word, ignoreCase = true) } + .map { word -> "\"$word\" in: $line" } + } + assertEquals("the lock screen must not name what this app is for", emptyList(), offenders) + } + + /** A guard over an empty list is not a guard. */ + @Test fun `the checked surface is not accidentally empty`() { + assertTrue("LockCopy.all must cover the visible strings", LockCopy.all.size >= 8) + assertTrue(NotificationCopy.SENSITIVE_WORDS.isNotEmpty()) + } + + /** + * The countdown is the one string built at runtime, so it is checked in the + * shape it actually renders rather than as a template. + */ + @Test fun `the countdown string is also clean`() { + listOf("30s", "1:00", "15:00").forEach { formatted -> + val line = LockCopy.tryAgainIn(formatted) + NotificationCopy.SENSITIVE_WORDS.forEach { word -> + assertTrue( + "\"$word\" appeared in: $line", + !line.contains(word, ignoreCase = true), + ) + } + } + } +} diff --git a/build.gradle.kts b/build.gradle.kts index db34c6a..afa56d5 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -44,13 +44,18 @@ plugins { val allowedProjectDependencies: Map> = mapOf( ":app" to setOf( ":core:designsystem", ":core:data", ":core:datastore", ":core:notifications", - ":domain:cycle", ":domain:prediction", + ":core:security", ":domain:cycle", ":domain:prediction", ), ":core:designsystem" to emptySet(), ":core:database" to setOf(":domain:cycle", ":domain:prediction"), ":core:datastore" to emptySet(), ":core:data" to setOf(":core:database", ":domain:cycle", ":domain:prediction"), ":core:notifications" to setOf(":core:data", ":core:datastore", ":domain:cycle", ":domain:prediction"), + // Empty on purpose, and it is load-bearing. The app lock's key material and + // its backoff state live here; a dependency on :core:data would make this a + // module that can see a cycle date, and the erase path deliberately runs in + // :app so that never has to happen. + ":core:security" to emptySet(), ":domain:cycle" to emptySet(), ":domain:prediction" to setOf(":domain:cycle"), // Batch 07. Empty, and that is the whole point: the ads module may reach @@ -214,6 +219,17 @@ val allowedPermissions: Set = setOf( "android.permission.ACCESS_NETWORK_STATE", "android.permission.RECEIVE_BOOT_COMPLETED", "android.permission.FOREGROUND_SERVICE", + + // The two androidx.biometric brings, for the app lock (§45). Neither is + // typed anywhere in this project's own manifest. + // + // USE_FINGERPRINT is the one that looks removable and is not. It is the + // pre-API-28 path, which minSdk 26 admits, and BiometricFragment reaches + // FingerprintManagerCompat through it — stripping it with tools:node="remove" + // would break the lock on exactly the oldest devices, which are the ones + // least able to fall back to anything else. + "android.permission.USE_BIOMETRIC", + "android.permission.USE_FINGERPRINT", ) /** @@ -339,7 +355,7 @@ tasks.register("checkPermissions") { /** Modules that can see a cycle date. `core/designsystem` cannot, so it is absent. */ val modulesSeeingHealthData: List = listOf( "app", "core/data", "core/database", "core/datastore", - "core/notifications", "domain/cycle", "domain/prediction", + "core/notifications", "core/security", "domain/cycle", "domain/prediction", ) /** diff --git a/core/security/build.gradle.kts b/core/security/build.gradle.kts new file mode 100644 index 0000000..cff1769 --- /dev/null +++ b/core/security/build.gradle.kts @@ -0,0 +1,35 @@ +plugins { + alias(libs.plugins.android.library) +} + +android { + namespace = "dev.privacyllc.period.core.security" + compileSdk = 37 + + defaultConfig { + minSdk = 26 + // AndroidKeyStore has no Robolectric shadow — 4.16.1 ships + // ShadowBiometricManager and ShadowKeyguardManager and no crypto shadows + // at all. So the key itself can only be tested on a device, and this + // module needs a runner where core/datastore does not. + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } +} + +dependencies { + api(libs.androidx.datastore.preferences) + implementation(libs.kotlinx.coroutines.core) + + testImplementation(libs.junit) + testImplementation(libs.kotlinx.coroutines.test) + + androidTestImplementation(libs.androidx.test.junit) + androidTestImplementation(libs.androidx.test.runner) + androidTestImplementation(libs.androidx.test.rules) + androidTestImplementation(libs.kotlinx.coroutines.test) +} diff --git a/core/security/src/androidTest/kotlin/dev/privacyllc/period/core/security/KeystoreVerifierTest.kt b/core/security/src/androidTest/kotlin/dev/privacyllc/period/core/security/KeystoreVerifierTest.kt new file mode 100644 index 0000000..425bef1 --- /dev/null +++ b/core/security/src/androidTest/kotlin/dev/privacyllc/period/core/security/KeystoreVerifierTest.kt @@ -0,0 +1,123 @@ +package dev.privacyllc.period.core.security + +import android.os.Build +import android.security.keystore.KeyInfo +import androidx.test.ext.junit.runners.AndroidJUnit4 +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import java.security.KeyStore +import javax.crypto.SecretKey +import javax.crypto.SecretKeyFactory + +/** + * The half that cannot run on the JVM. + * + * `KeyStore.getInstance("AndroidKeyStore")` throws `AndroidKeyStore not found` + * under Robolectric — verified, not assumed — so the key's real properties can + * only be checked on a device. Everything else about the lock is covered by the + * JVM tests in this module, which is why this file is short. + * + * Must be run on **`PeriodMinSdk26`** as well as a modern image: API 26 is where + * `PBKDF2WithHmacSHA256` first exists, so it is the one algorithm choice in this + * feature with no margin at all. + */ +@RunWith(AndroidJUnit4::class) +class KeystoreVerifierTest { + + private val alias = "period.applock.test.v1" + private lateinit var macs: AndroidKeyStoreMacProvider + + private fun store(): KeyStore = + KeyStore.getInstance(AndroidKeyStoreMacProvider.ANDROID_KEYSTORE).apply { load(null) } + + @Before fun setUp() { + // Without this, a device that has already run the un-mutated build has + // the key under this alias, ensureKey() returns early, and the test + // passes over a spec it never checked. + runCatching { store().deleteEntry(alias) } + macs = AndroidKeyStoreMacProvider(alias) + } + + @After fun tearDown() { + runCatching { store().deleteEntry(alias) } + } + + @Test fun theAlgorithmThisFeatureNeedsExistsAtThisApiLevel() { + // API 26 exactly. A NoSuchAlgorithmException here would mean the lock + // cannot be set on the oldest supported device, which is a shipped + // feature that does not work rather than one that degrades. + val factory = SecretKeyFactory.getInstance(PinVerifier.PBKDF2) + assertEquals(PinVerifier.PBKDF2, factory.algorithm) + } + + @Test fun theKeyIsCreatedOnceAndSurvivesReuse() { + assertFalse(macs.hasKey()) + macs.ensureKey() + assertTrue(macs.hasKey()) + + val first = macs.mac("payload".toByteArray()) + + // A second provider over the same alias is what a process restart looks + // like from here. + val reopened = AndroidKeyStoreMacProvider(alias) + reopened.ensureKey() + assertTrue( + "the key must survive, or every stored record becomes unverifiable", + first.contentEquals(reopened.mac("payload".toByteArray())), + ) + } + + /** + * The single most important assertion in the feature. + * + * A key bound to user authentication is destroyed when the device passcode + * changes or the screen lock is removed. With no recovery path, that is the + * user's entire history gone because they changed their phone's PIN. + */ + @Test fun theKeyIsNotBoundToUserAuthentication() { + macs.ensureKey() + val key = store().getKey(alias, null) as SecretKey + + assertNull("an AndroidKeyStore secret key must never be exportable", key.encoded) + + val info = SecretKeyFactory + .getInstance(key.algorithm, AndroidKeyStoreMacProvider.ANDROID_KEYSTORE) + .getKeySpec(key, KeyInfo::class.java) as KeyInfo + + assertEquals(AndroidKeyStoreMacProvider.KEY_BITS, info.keySize) + assertFalse( + "a key requiring user authentication dies with a passcode change, " + + "and under no-recovery that is the history gone", + info.isUserAuthenticationRequired, + ) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + assertFalse(info.isInvalidatedByBiometricEnrollment) + } + } + + /** A missing key must take the "this device cannot check" branch, not "wrong PIN". */ + @Test fun aDeletedKeyIsReportedAsUnavailableRatherThanWrong() { + val verifier = PinVerifier(macs) + val record = verifier.enroll("2468".toCharArray(), iterations = 1_000) + assertTrue(verifier.verify("2468".toCharArray(), record) is PinCheck.Correct) + + macs.deleteKey() + + assertEquals(PinCheck.KeyUnavailable, verifier.verify("2468".toCharArray(), record)) + } + + @Test fun theRealKeyProducesADifferentMacPerTag() { + macs.ensureKey() + val payload = ByteArray(8) { it.toByte() } + assertFalse( + macs.mac(byteArrayOf(PinVerifier.TAG_VERIFIER) + payload) + .contentEquals(macs.mac(byteArrayOf(PinVerifier.TAG_BACKOFF) + payload)), + ) + } +} diff --git a/core/security/src/main/kotlin/dev/privacyllc/period/core/security/AndroidKeyStoreMacProvider.kt b/core/security/src/main/kotlin/dev/privacyllc/period/core/security/AndroidKeyStoreMacProvider.kt new file mode 100644 index 0000000..fc50458 --- /dev/null +++ b/core/security/src/main/kotlin/dev/privacyllc/period/core/security/AndroidKeyStoreMacProvider.kt @@ -0,0 +1,89 @@ +package dev.privacyllc.period.core.security + +import android.security.keystore.KeyGenParameterSpec +import android.security.keystore.KeyProperties +import java.security.KeyStore +import javax.crypto.KeyGenerator +import javax.crypto.Mac +import javax.crypto.SecretKey + +/** + * The signing key for the PIN verifier, and the shortest file in this feature + * for a reason: everything testable lives elsewhere. + * + * ## The omissions are the design + * + * The builder below sets three things and deliberately omits six. Each omission + * is what keeps this key **alive** across something the user is entitled to do, + * and under the no-recovery policy a key that dies takes their entire history + * with it. Anyone tempted to "harden" this should read the list first: + * + * - **`setUserAuthenticationRequired`** — the big one. It would bind this key to + * the *device* lock screen, so changing a passcode, or removing the screen + * lock, destroys it. It would also be a straightforward bypass: whoever knows + * the phone's own PIN would unlock this app with it, making the app lock + * exactly as strong as the lock it exists to sit behind. `SECURITY.md` names + * "someone who knows the unlock PIN" as an adversary this app cannot stop — + * binding to that PIN would hand them the app as well. + * - **`setUserAuthenticationParameters`** — meaningless without the above. + * - **`setInvalidatedByBiometricEnrollment`** — a no-op without user + * authentication, and named here so nobody adds it thinking it does something. + * The *biometric* key in [BiometricKey] does set it, correctly, because + * invalidation there degrades to "use your PIN" instead of to lockout. + * - **`setUnlockedDeviceRequired`** — would fail exactly in the background, + * where the reminder worker runs with the screen off. + * - **`setIsStrongBoxBacked`** — StrongBox defends against extracting the key + * from the TEE, which is an adversary `SECURITY.md` explicitly declines to + * buy defences for. It throws `StrongBoxUnavailableException` on devices + * without it, so it adds a fallback path, and every fallback path is another + * way to end up holding a *different* key than the one that made the record. + * - **`setKeyValidityEnd` / `setMaxUsageCount`** — an expiring key is an + * expiring history. + * + * ## It is created once and never re-created + * + * [ensureKey] is called at enrolment. Nothing calls it to recover from a + * failure, and nothing may: a fresh key cannot verify an existing record, so + * minting one inside a `catch` converts a transient Keystore fault into + * permanent, silent loss. When the key is gone, [hasKey] returns false and the + * caller takes the `KeyUnavailable` branch, which says so honestly. + */ +internal class AndroidKeyStoreMacProvider( + private val alias: String = PIN_KEY_ALIAS, +) : MacProvider { + + private fun store(): KeyStore = + KeyStore.getInstance(ANDROID_KEYSTORE).apply { load(null) } + + override fun hasKey(): Boolean = + runCatching { store().containsAlias(alias) }.getOrDefault(false) + + override fun ensureKey() { + if (hasKey()) return + KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_HMAC_SHA256, ANDROID_KEYSTORE).apply { + init( + KeyGenParameterSpec.Builder(alias, KeyProperties.PURPOSE_SIGN) + .setKeySize(KEY_BITS) + .setDigests(KeyProperties.DIGEST_SHA256) + .build(), + ) + }.generateKey() + } + + override fun mac(data: ByteArray): ByteArray { + val key = store().getKey(alias, null) as? SecretKey + ?: throw IllegalStateException("the verifier key is absent") + return Mac.getInstance(MAC_ALGORITHM).apply { init(key) }.doFinal(data) + } + + override fun deleteKey() { + runCatching { store().deleteEntry(alias) } + } + + companion object { + const val ANDROID_KEYSTORE = "AndroidKeyStore" + const val PIN_KEY_ALIAS = "period.applock.pin.v1" + const val MAC_ALGORITHM = "HmacSHA256" + const val KEY_BITS = 256 + } +} diff --git a/core/security/src/main/kotlin/dev/privacyllc/period/core/security/AppLockRepository.kt b/core/security/src/main/kotlin/dev/privacyllc/period/core/security/AppLockRepository.kt new file mode 100644 index 0000000..755aa5e --- /dev/null +++ b/core/security/src/main/kotlin/dev/privacyllc/period/core/security/AppLockRepository.kt @@ -0,0 +1,212 @@ +package dev.privacyllc.period.core.security + +import android.os.SystemClock +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringPreferencesKey +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import java.security.MessageDigest +import java.util.Base64 + +/** What happened when a PIN was offered. */ +sealed interface UnlockResult { + data object Unlocked : UnlockResult + + /** Wrong PIN. [waitMillis] is zero while the user still has free attempts. */ + data class Wrong(val waitMillis: Long) : UnlockResult + + /** Refused without checking, because a lockout is still running. */ + data class TooSoon(val waitMillis: Long) : UnlockResult + + /** No PIN has been set on this device. */ + data object NoPin : UnlockResult + + /** + * The Keystore entry is gone, so no PIN can ever match again. + * + * Deliberately distinct from [Wrong]. Telling somebody their correct PIN is + * wrong, forever, with an ever-growing delay, is the cruellest failure this + * feature could produce — and under the no-recovery policy they would have + * no way to find out otherwise. + */ + data object KeyUnavailable : UnlockResult +} + +/** + * Everything the app lock persists, in its own DataStore file. + * + * ## Why not `UserPreferences` + * + * `UserPreferencesRepository.resetToDefaults()` is `edit { it.clear() }`. It has + * no production caller today, and the day it gets one, a verifier record living + * in that store would be silently removed — locking the user out of their own + * history with no way back. The two stores also have deliberately different + * deletion semantics: Delete My Data keeps your settings, and the erase hatch + * destroys the lock. Keeping them apart is what lets both of those be true. + * + * ## There is no `pinSet` boolean + * + * It is derived from whether a verifier record exists. A separate flag would be + * a second record of one fact, and the two would eventually disagree — with the + * disagreement showing up as either a lock nobody can open or an app that never + * locks. + * + * The store is injected rather than built from a `Context`, mirroring + * `UserPreferencesRepository` for the same reason: it can then be exercised + * against a temporary file without an emulator. + */ +class AppLockRepository internal constructor( + private val store: DataStore, + private val macs: MacProvider, + private val clocks: Clocks, +) { + + /** + * The constructor callers outside this module use. + * + * `internal` on the primary one is the same mechanism `CycleRepository` + * uses to keep Room off everyone else's compile classpath: `MacProvider` and + * `Clocks` are seams for this module's own tests, and nothing above needs to + * be able to name them — or to substitute them. + */ + constructor(store: DataStore) : + this(store, AndroidKeyStoreMacProvider(), SystemClocks) + + private val verifier: PinVerifier = PinVerifier(macs) + + /** True once a PIN exists. Derived, never stored. */ + val hasPin: Flow = store.data.map { it[Keys.Verifier] != null } + + suspend fun hasPinNow(): Boolean = hasPin.first() + + /** + * Set the first PIN, or replace one after the current PIN has been checked. + * + * Returns false only if the Keystore refuses to produce a key at all, which + * is the one case where the honest thing is to leave the lock off rather + * than enable a lock that cannot be opened. + */ + suspend fun setPin(pin: CharArray): Boolean { + val record = runCatching { verifier.enroll(pin) }.getOrNull() ?: return false + store.edit { + it[Keys.Verifier] = record.encode() + it.remove(Keys.Lockout) + it.remove(Keys.LockoutMac) + } + return true + } + + /** + * Offer a PIN. + * + * A running lockout is checked **before** the PIN is, so guessing during a + * lockout costs an attempt and tells them nothing. + */ + suspend fun check(pin: CharArray): UnlockResult { + val prefs = store.data.first() + val record = VerifierRecord.decode(prefs[Keys.Verifier]) ?: return UnlockResult.NoPin + + val state = readLockout(prefs) + val wait = LockoutPolicy.remainingMillis(state, clocks) + if (wait > 0L) return UnlockResult.TooSoon(wait) + + return when (val outcome = runCatching { verifier.verify(pin, record) }.getOrNull()) { + null -> UnlockResult.KeyUnavailable + is PinCheck.KeyUnavailable -> UnlockResult.KeyUnavailable + + is PinCheck.Correct -> { + store.edit { edited -> + outcome.rehashed?.let { edited[Keys.Verifier] = it.encode() } + edited.remove(Keys.Lockout) + edited.remove(Keys.LockoutMac) + } + UnlockResult.Unlocked + } + + is PinCheck.Wrong -> { + val next = LockoutPolicy.onFailure(state ?: LockoutState(), clocks) + writeLockout(next) + UnlockResult.Wrong(LockoutPolicy.remainingMillis(next, clocks)) + } + } + } + + /** Milliseconds still to wait, for a screen that has to render a countdown. */ + suspend fun lockoutRemainingMillis(): Long = + LockoutPolicy.remainingMillis(readLockout(store.data.first()), clocks) + + /** + * Remove the lock entirely: the record, the counter, and the key itself. + * + * The key deletion is the step that must not be dropped. Without it a user + * who erases everything keeps a working lock over an empty database — + * erased *and* still locked out — which is the single worst outcome this + * feature can produce, and is why a test mutates this line out and requires + * a red. + */ + suspend fun clearLock() { + macs.deleteKey() + store.edit { + it.remove(Keys.Verifier) + it.remove(Keys.Lockout) + it.remove(Keys.LockoutMac) + } + } + + /** + * Null means "there is a counter and it does not check out", which + * [LockoutPolicy] treats as maximum backoff. + * + * It must **not** mean "there is no counter". A fresh install has neither + * key, and so does a device where the last unlock succeeded and cleared + * them — reporting either as tampering would open the app in a + * fifteen-minute lockout that nobody had earned. Both keys absent is the + * ordinary empty state; one of them absent is somebody having removed it. + */ + private fun readLockout(prefs: Preferences): LockoutState? { + val blob = prefs[Keys.Lockout] + val stored = prefs[Keys.LockoutMac] + if (blob == null && stored == null) return LockoutState() + if (blob == null || stored == null) return null + val expected = runCatching { macOf(blob) }.getOrNull() ?: return null + // A tampered or unverifiable counter reads as null, and null is maximum + // backoff in LockoutPolicy — so editing the file is the worst available + // move rather than the best one. + if (!MessageDigest.isEqual(expected, stored.decodeB64())) return null + return LockoutState.decode(blob) + } + + private suspend fun writeLockout(state: LockoutState) { + val blob = state.encode() + val mac = runCatching { macOf(blob) }.getOrNull() ?: return + store.edit { + it[Keys.Lockout] = blob + it[Keys.LockoutMac] = mac.encodeB64() + } + } + + /** Tag 0x02, so a counter value can never be replayed as a verifier value. */ + private fun macOf(blob: String): ByteArray = + macs.mac(byteArrayOf(PinVerifier.TAG_BACKOFF) + blob.toByteArray(Charsets.UTF_8)) + + private fun ByteArray.encodeB64(): String = + Base64.getEncoder().withoutPadding().encodeToString(this) + + private fun String.decodeB64(): ByteArray = + runCatching { Base64.getDecoder().decode(this) }.getOrDefault(ByteArray(0)) + + private object Keys { + val Verifier = stringPreferencesKey("pin_verifier_v1") + val Lockout = stringPreferencesKey("lockout_state_v1") + val LockoutMac = stringPreferencesKey("lockout_state_mac_v1") + } +} + +/** The real clocks. Split out so tests can move time without waiting. */ +internal object SystemClocks : Clocks { + override fun wallMillis(): Long = System.currentTimeMillis() + override fun elapsedMillis(): Long = SystemClock.elapsedRealtime() +} diff --git a/core/security/src/main/kotlin/dev/privacyllc/period/core/security/LockoutPolicy.kt b/core/security/src/main/kotlin/dev/privacyllc/period/core/security/LockoutPolicy.kt new file mode 100644 index 0000000..bbc66cc --- /dev/null +++ b/core/security/src/main/kotlin/dev/privacyllc/period/core/security/LockoutPolicy.kt @@ -0,0 +1,109 @@ +package dev.privacyllc.period.core.security + +/** + * What happens after a wrong PIN. + * + * Four free attempts, then a delay that grows 30s → 1m → 2m → 5m → 15m and + * stays at fifteen minutes **forever**. There is no attempt limit and there is + * no automatic erase, and both of those are decisions rather than omissions. + * + * ## Why it never wipes + * + * A wipe-after-N-attempts policy is common, and here it would be a weapon. + * `SECURITY.md`'s threat model names people with physical access to the phone — + * a partner, someone on a shared device — and under the no-recovery policy an + * automatic erase would let any of them destroy a cycle history permanently + * without knowing anything at all. So could a child, or a pocket. The thing this + * app is protecting is the one thing a wrong guess must never be able to + * destroy. + * + * Fifteen minutes forever is enough. A million six-digit PINs at four per + * fifteen minutes is longer than the phone will exist. + * + * ## Why it never shows a count + * + * "3 attempts remaining" tells an attacker how much room they have and tells the + * owner they are about to lose everything. The screen says only how long to + * wait. + */ +internal object LockoutPolicy { + + /** Wrong PINs allowed before any delay. A mistyped digit should not cost a wait. */ + const val FREE_ATTEMPTS = 4 + + /** Applied after [FREE_ATTEMPTS]; the last value repeats for every failure after it. */ + val DELAYS_MILLIS: List = listOf( + 30_000L, + 60_000L, + 120_000L, + 300_000L, + 900_000L, + ) + + val MAX_DELAY_MILLIS: Long = DELAYS_MILLIS.last() + + /** The delay earned by [failures] wrong attempts in total. */ + fun delayFor(failures: Int): Long { + val over = failures - FREE_ATTEMPTS + if (over <= 0) return 0L + return DELAYS_MILLIS[minOf(over - 1, DELAYS_MILLIS.lastIndex)] + } + + /** A wrong PIN. Advances the count and arms both deadlines. */ + fun onFailure(state: LockoutState, clocks: Clocks): LockoutState { + val failures = state.failedAttempts + 1 + val delay = delayFor(failures) + val wall = clocks.wallMillis() + val elapsed = clocks.elapsedMillis() + return LockoutState( + failedAttempts = failures, + wallDeadline = wall + delay, + elapsedDeadline = elapsed + delay, + elapsedMark = elapsed, + ) + } + + /** A correct PIN clears everything. Nothing is remembered across a success. */ + fun onSuccess(): LockoutState = LockoutState() + + /** + * Milliseconds still to wait, or zero when the user may try again. + * + * Three rules, and each closes something: + * + * 1. **Take the longer of the two clocks.** Moving the device clock forward + * expires the wall deadline; the elapsed one is untouched and still + * holds. Moving it backward inflates the wall remainder; rule 3 caps it. + * 2. **A reboot re-applies the full delay.** `elapsedRealtime` restarts at + * zero, so a reading *below* the mark taken when the deadline was written + * can only mean the phone restarted — at which point the elapsed deadline + * is meaningless and the wall one may have been tampered with. Failing + * safe costs an honest user one more wait and costs an attacker the + * entire reboot bypass. + * 3. **Never longer than the delay actually earned.** Without this, setting + * the clock back a year would lock the owner out for a year — turning a + * defence into the denial-of-service it was meant to prevent. + */ + fun remainingMillis(state: LockoutState?, clocks: Clocks): Long { + // A counter that exists and does not verify is maximum backoff, never + // zero — so editing it is the worst move available rather than the best + // one. Note the caller distinguishes this from "no counter yet", which + // is the ordinary state of a fresh install and passes LockoutState(). + if (state == null) return MAX_DELAY_MILLIS + + val earned = delayFor(state.failedAttempts) + if (earned == 0L) return 0L + + val elapsed = clocks.elapsedMillis() + if (elapsed < state.elapsedMark) return earned + + val byWall = state.wallDeadline - clocks.wallMillis() + val byElapsed = state.elapsedDeadline - elapsed + val remaining = maxOf(byWall, byElapsed) + + return remaining.coerceIn(0L, earned) + } + + fun isLockedOut(state: LockoutState?, clocks: Clocks): Boolean = + remainingMillis(state, clocks) > 0L +} diff --git a/core/security/src/main/kotlin/dev/privacyllc/period/core/security/LockoutState.kt b/core/security/src/main/kotlin/dev/privacyllc/period/core/security/LockoutState.kt new file mode 100644 index 0000000..2adb90d --- /dev/null +++ b/core/security/src/main/kotlin/dev/privacyllc/period/core/security/LockoutState.kt @@ -0,0 +1,62 @@ +package dev.privacyllc.period.core.security + +/** + * The two clocks this feature needs, behind an interface so the awkward cases + * are testable without a device and without waiting fifteen minutes. + * + * Neither clock is sufficient alone, and that is the whole reason this type + * exists: + * + * - **Wall time** is user-settable. Someone locked out for fifteen minutes can + * open Settings, move the date forward, and come back to an expired lockout. + * - **Elapsed real time** is monotonic and cannot be set — but it resets to zero + * on reboot, so it forgets every deadline the moment the phone restarts. + * + * Used together they cover each other, provided the answer is always the + * *longer* remaining wait rather than the shorter one. + */ +internal interface Clocks { + /** `System.currentTimeMillis()` — settable by the user. */ + fun wallMillis(): Long + + /** `SystemClock.elapsedRealtime()` — monotonic, resets on boot. */ + fun elapsedMillis(): Long +} + +/** + * How many wrong PINs there have been, and until when. + * + * Persisted with a MAC over it (tag `0x02`), so an edit is detectable. It is + * not tamper-*proof* and does not need to be: anyone who can rewrite this app's + * private files already has root and can read the unencrypted cycle database + * directly, and Android's own Settings → Clear storage resets this counter while + * destroying the database in the same action. The MAC is there so tampering + * fails **closed** rather than silently succeeding. + */ +internal data class LockoutState( + val failedAttempts: Int = 0, + /** Wall-clock instant the lockout ends. Meaningless if the user moves the clock. */ + val wallDeadline: Long = 0L, + /** Elapsed-time instant the lockout ends. Meaningless after a reboot. */ + val elapsedDeadline: Long = 0L, + /** Elapsed reading when the deadline was written. A smaller reading now means a reboot. */ + val elapsedMark: Long = 0L, +) { + fun encode(): String = "$VERSION|$failedAttempts|$wallDeadline|$elapsedDeadline|$elapsedMark" + + companion object { + const val VERSION = "v1" + + /** Null for anything unparseable. The caller treats null as maximum backoff, never as zero. */ + fun decode(raw: String?): LockoutState? { + val p = raw?.split("|") ?: return null + if (p.size != 5 || p[0] != VERSION) return null + return LockoutState( + failedAttempts = p[1].toIntOrNull()?.takeIf { it >= 0 } ?: return null, + wallDeadline = p[2].toLongOrNull() ?: return null, + elapsedDeadline = p[3].toLongOrNull() ?: return null, + elapsedMark = p[4].toLongOrNull() ?: return null, + ) + } + } +} diff --git a/core/security/src/main/kotlin/dev/privacyllc/period/core/security/MacProvider.kt b/core/security/src/main/kotlin/dev/privacyllc/period/core/security/MacProvider.kt new file mode 100644 index 0000000..1526e6c --- /dev/null +++ b/core/security/src/main/kotlin/dev/privacyllc/period/core/security/MacProvider.kt @@ -0,0 +1,47 @@ +package dev.privacyllc.period.core.security + +/** + * The one operation that needs hardware, behind an interface so everything else + * can be tested in a second on the JVM. + * + * The split is deliberate and follows this project's stated reason for keeping + * the `domain` modules free of Android: the interesting logic here — the + * record format, the stretching, the domain separation, the constant-time + * compare, the + * rehash-on-unlock rule — is arithmetic, and arithmetic tested on an emulator is + * arithmetic tested when somebody remembers to boot one. Only + * [AndroidKeyStoreMacProvider] needs a device, and it is deliberately thin. + * + * There is no Robolectric shortcut available: 4.16.1 ships `ShadowBiometricManager` + * and `ShadowKeyguardManager` and no crypto shadows at all, so + * `KeyStore.getInstance("AndroidKeyStore")` cannot work on the host JVM. That is + * a reason to make this seam small, not a reason to skip the device test. + */ +interface MacProvider { + + /** True when the signing key exists. False after [deleteKey], or on a fresh install. */ + fun hasKey(): Boolean + + /** + * Create the signing key if it is absent. + * + * Called once, at enrolment. **Never call this to recover from a failure.** + * A new key cannot verify an old record, so minting one in a `catch` turns + * "we could not check your PIN" into "your PIN is now wrong forever", and + * under the no-recovery policy that is the user's history gone. + */ + fun ensureKey() + + /** HMAC-SHA256 over [data] with the non-exportable key. */ + fun mac(data: ByteArray): ByteArray + + /** + * Destroy the signing key. + * + * Only the erase path calls this, and it must, or a user who erases + * everything is left with a working lock over an empty database — erased + * *and* still locked out, which is the worst outcome this feature can + * produce. + */ + fun deleteKey() +} diff --git a/core/security/src/main/kotlin/dev/privacyllc/period/core/security/PinVerifier.kt b/core/security/src/main/kotlin/dev/privacyllc/period/core/security/PinVerifier.kt new file mode 100644 index 0000000..d43afe0 --- /dev/null +++ b/core/security/src/main/kotlin/dev/privacyllc/period/core/security/PinVerifier.kt @@ -0,0 +1,133 @@ +package dev.privacyllc.period.core.security + +import java.security.MessageDigest +import java.security.SecureRandom +import javax.crypto.SecretKeyFactory +import javax.crypto.spec.PBEKeySpec + +/** + * The three outcomes of checking a PIN, and they are three rather than two on + * purpose. + * + * [KeyUnavailable] is the one that matters. If the Keystore entry has gone — + * a restored device, a cleared keystore, an OEM upgrade that dropped it — then + * no PIN can ever verify again. Reporting that as [Wrong] would send the user + * into a backoff that gets longer forever while they type a PIN that was always + * correct, and under the no-recovery policy they would have no way to learn + * otherwise. It has to be a different branch with different words on screen. + */ +internal sealed interface PinCheck { + + /** + * @param rehashed a record at the current cost, when the stored one was + * cheaper. Non-null only here, because the PIN exists in memory only at + * this instant — see [VerifierRecord]. + */ + data class Correct(val rehashed: VerifierRecord?) : PinCheck + + data object Wrong : PinCheck + + data object KeyUnavailable : PinCheck +} + +/** + * Turns a PIN into something that can be checked without storing it. + * + * ``` + * stretched = PBKDF2WithHmacSHA256(pin, salt, iterations, 256 bits) + * mac = HMAC-SHA256(keystoreKey, 0x01 || salt || stretched) + * ``` + * + * Two layers, because they defend against different things and neither is + * sufficient alone. + * + * **The Keystore MAC is what makes a six-digit PIN safe.** A PIN has at most a + * million values; any hash an attacker can compute offline falls in seconds, at + * any iteration count. The key here cannot be exported from the Android + * Keystore, so an attacker holding a copy of the app's files cannot try even one + * candidate without the physical device. + * + * **PBKDF2 underneath is for the day that assumption breaks.** If the key ever + * leaks, the stretch is the only thing between the record and the PIN. + * + * `0x01` is a domain-separation tag. The same Keystore key also MACs the + * lockout counter under `0x02`, and without a tag a value from one context could + * be replayed as the other. + */ +internal class PinVerifier( + private val macs: MacProvider, + private val random: SecureRandom = SecureRandom(), +) { + + /** Creates the key if absent, and returns the record to persist. */ + fun enroll(pin: CharArray, iterations: Int = TARGET_ITERATIONS): VerifierRecord { + macs.ensureKey() + val salt = ByteArray(SALT_BYTES).also(random::nextBytes) + return VerifierRecord(iterations, salt, macFor(pin, salt, iterations)) + } + + fun verify(pin: CharArray, record: VerifierRecord): PinCheck { + if (!macs.hasKey()) return PinCheck.KeyUnavailable + + val candidate = macFor(pin, record.salt, record.iterations) + // MessageDigest.isEqual, not contentEquals: this is the one comparison + // in the app where an early return leaks how much of a guess was right. + // Hex-string comparison would leak the same way and is the usual mistake. + if (!MessageDigest.isEqual(candidate, record.mac)) return PinCheck.Wrong + + // The PIN is in memory exactly here and nowhere else, so this is the + // only moment a cheaper record can be brought up to the current cost. + val rehashed = if (record.iterations < TARGET_ITERATIONS) enroll(pin) else null + return PinCheck.Correct(rehashed) + } + + private fun macFor(pin: CharArray, salt: ByteArray, iterations: Int): ByteArray { + var stretched: ByteArray? = null + try { + stretched = stretch(pin, salt, iterations) + return macs.mac(byteArrayOf(TAG_VERIFIER) + salt + stretched) + } finally { + // Best-effort, and worth doing anyway: these are ByteArrays we own, + // unlike the String a text field hands us, which is immutable and + // cannot be cleared at all. The KDoc on the lock screen explains + // why a custom keypad is still not worth the accessibility cost. + stretched?.fill(0) + } + } + + private fun stretch(pin: CharArray, salt: ByteArray, iterations: Int): ByteArray { + val spec = PBEKeySpec(pin, salt, iterations, STRETCHED_BITS) + try { + return SecretKeyFactory.getInstance(PBKDF2).generateSecret(spec).encoded + } finally { + spec.clearPassword() + } + } + + companion object { + /** + * `PBKDF2WithHmacSHA256` arrived in API 26, which is this project's + * minSdk exactly — so this is the one algorithm choice with no margin, + * and it is checked on the `PeriodMinSdk26` emulator rather than assumed. + */ + const val PBKDF2 = "PBKDF2WithHmacSHA256" + + const val SALT_BYTES = 16 + const val STRETCHED_BITS = 256 + + /** + * Chosen for an unlock a person waits through, not for a password + * database. The usual published figures assume the hash can be attacked + * offline; here it cannot, because the MAC key never leaves the + * Keystore — so this is a second line rather than the only one, and + * buying it at a second of latency on an old phone would be paying in + * the wrong currency. Raise it by bumping this: a cheaper stored record + * is re-derived on the next successful unlock. + */ + const val TARGET_ITERATIONS = 210_000 + + /** Domain-separation tags. The lockout counter uses [TAG_BACKOFF]. */ + const val TAG_VERIFIER: Byte = 0x01 + const val TAG_BACKOFF: Byte = 0x02 + } +} diff --git a/core/security/src/main/kotlin/dev/privacyllc/period/core/security/VerifierRecord.kt b/core/security/src/main/kotlin/dev/privacyllc/period/core/security/VerifierRecord.kt new file mode 100644 index 0000000..69fffb7 --- /dev/null +++ b/core/security/src/main/kotlin/dev/privacyllc/period/core/security/VerifierRecord.kt @@ -0,0 +1,89 @@ +package dev.privacyllc.period.core.security + +// java.util.Base64, not android.util.Base64, and it is not a style choice: the +// Android one is a stub on the host JVM, so using it would push every test of +// the record format onto an emulator. java.util is available from API 26, which +// is this project's minSdk exactly. +import java.util.Base64 + +/** + * What is stored so a PIN can be checked, and deliberately not the PIN. + * + * `v1 | iterations | salt | mac`, each part Base64 where it is bytes. + * + * ## Why the version and the iteration count ship from the first commit + * + * Neither is needed today and both are impossible to add later. Raising the + * PBKDF2 cost means re-deriving from the PIN, and the only instant the PIN + * exists in memory is immediately after a successful unlock — so a record with + * no iteration field can never be told apart from one at the new cost, and + * under the no-recovery policy there is no "ask them to set it again" fallback + * to fall back to. A discriminator that costs six characters now cannot be + * retrofitted at any price. + * + * ## What an attacker holding this file gets + * + * Nothing usable without the device. The MAC is taken with a key that lives in + * the Android Keystore and cannot be exported, so a six-digit PIN — trivially + * brute-forced against a plain hash — cannot be attacked offline at all. The + * PBKDF2 layer underneath is defence for the case where that assumption fails. + */ +internal data class VerifierRecord( + val iterations: Int, + val salt: ByteArray, + val mac: ByteArray, +) { + fun encode(): String = listOf( + VERSION, + iterations.toString(), + salt.b64(), + mac.b64(), + ).joinToString(SEPARATOR) + + // Generated equals/hashCode would compare the arrays by identity, which + // makes two identical records unequal and is the classic data-class-with- + // ByteArray trap. Tests compare records; they must compare contents. + override fun equals(other: Any?): Boolean = + this === other || ( + other is VerifierRecord && + iterations == other.iterations && + salt.contentEquals(other.salt) && + mac.contentEquals(other.mac) + ) + + override fun hashCode(): Int = + (iterations * 31 + salt.contentHashCode()) * 31 + mac.contentHashCode() + + /** + * Never renders the salt or the MAC. + * + * Same rule as `PeriodRecord` and `Prediction` in the `domain` modules: a + * data class prints its own contents into any exception message or crash + * payload that + * interpolates it, and nobody has to write a logging call for that to + * happen. `checkNoHealthLogging` cannot see it either. + */ + override fun toString(): String = "VerifierRecord(v=$VERSION, iterations=$iterations)" + + companion object { + const val VERSION = "v1" + private const val SEPARATOR = "|" + private const val PARTS = 4 + + /** Null for anything unparseable — a corrupt record is "no PIN set", never a crash. */ + fun decode(raw: String?): VerifierRecord? { + val parts = raw?.split(SEPARATOR) ?: return null + if (parts.size != PARTS || parts[0] != VERSION) return null + val iterations = parts[1].toIntOrNull()?.takeIf { it > 0 } ?: return null + val salt = parts[2].fromB64() ?: return null + val mac = parts[3].fromB64() ?: return null + if (salt.isEmpty() || mac.isEmpty()) return null + return VerifierRecord(iterations, salt, mac) + } + + private fun ByteArray.b64(): String = Base64.getEncoder().withoutPadding().encodeToString(this) + + private fun String.fromB64(): ByteArray? = + runCatching { Base64.getDecoder().decode(this) }.getOrNull() + } +} diff --git a/core/security/src/test/kotlin/dev/privacyllc/period/core/security/AppLockRepositoryTest.kt b/core/security/src/test/kotlin/dev/privacyllc/period/core/security/AppLockRepositoryTest.kt new file mode 100644 index 0000000..a324eda --- /dev/null +++ b/core/security/src/test/kotlin/dev/privacyllc/period/core/security/AppLockRepositoryTest.kt @@ -0,0 +1,156 @@ +package dev.privacyllc.period.core.security + +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.PreferenceDataStoreFactory +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringPreferencesKey +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder + +/** + * The lock's storage, on the JVM against a real DataStore in a temporary file. + * + * Possible for the same reason `UserPreferencesRepositoryTest` is: the + * repository takes a `DataStore` rather than a `Context`. The Keystore is the + * only part that cannot come along — `AndroidKeyStore not found` is what + * Robolectric returns, checked rather than assumed — so [FakeMacProvider] stands + * in for it and the instrumented test covers the real thing. + */ +class AppLockRepositoryTest { + + @get:Rule val tmp = TemporaryFolder() + + private lateinit var scope: TestScope + private lateinit var store: DataStore + private lateinit var macs: FakeMacProvider + private lateinit var clocks: FakeClocks + private lateinit var repo: AppLockRepository + + private val cheapPin get() = "2468".toCharArray() + + @Before fun setUp() { + scope = TestScope(StandardTestDispatcher()) + store = PreferenceDataStoreFactory.create( + scope = CoroutineScope(scope.coroutineContext), + produceFile = { tmp.newFile("app_lock.preferences_pb") }, + ) + macs = FakeMacProvider() + clocks = FakeClocks() + repo = AppLockRepository(store, macs, clocks) + } + + @After fun tearDown() = scope.cancel() + + @Test fun `a fresh install has no PIN`() = scope.runTest { + assertFalse(repo.hasPin.first()) + assertEquals(UnlockResult.NoPin, repo.check(cheapPin)) + } + + @Test fun `setting a PIN turns the lock on, and the right PIN opens it`() = scope.runTest { + assertTrue(repo.setPin(cheapPin)) + assertTrue(repo.hasPin.first()) + assertEquals(UnlockResult.Unlocked, repo.check(cheapPin)) + } + + @Test fun `the wrong PIN does not open it`() = scope.runTest { + repo.setPin(cheapPin) + assertTrue(repo.check("1111".toCharArray()) is UnlockResult.Wrong) + } + + @Test fun `wrong PINs eventually cost time, and the right one clears it`() = scope.runTest { + repo.setPin(cheapPin) + repeat(LockoutPolicy.FREE_ATTEMPTS) { + assertEquals( + "the first few mistakes should cost nothing", + 0L, + (repo.check("1111".toCharArray()) as UnlockResult.Wrong).waitMillis, + ) + } + assertTrue((repo.check("1111".toCharArray()) as UnlockResult.Wrong).waitMillis > 0L) + + // Guessing during a lockout is refused without being checked. + assertTrue(repo.check(cheapPin) is UnlockResult.TooSoon) + + clocks.advance(LockoutPolicy.MAX_DELAY_MILLIS) + assertEquals(UnlockResult.Unlocked, repo.check(cheapPin)) + assertEquals("a success forgets every failure", 0L, repo.lockoutRemainingMillis()) + } + + /** + * The catastrophic bug this feature can produce, stated as a test. + * + * If the erase leaves the Keystore key behind, the user has destroyed + * everything they recorded **and is still locked out** — a working lock over + * an empty database, with no PIN that opens it. `prove-guard.sh` mutates the + * `macs.deleteKey()` call out of [AppLockRepository.clearLock] and requires + * exactly this test to go red. + */ + @Test fun `clearing the lock destroys the key, not just the record`() = scope.runTest { + repo.setPin(cheapPin) + assertTrue(macs.hasKey()) + + repo.clearLock() + + assertFalse("the stored verifier must go", repo.hasPin.first()) + assertFalse("the Keystore key must go with it", macs.hasKey()) + assertEquals(1, macs.deleteCalls) + + // And the app must be usable again afterwards, which is the point. + assertEquals(UnlockResult.NoPin, repo.check(cheapPin)) + assertTrue("a new PIN can be set after an erase", repo.setPin("1357".toCharArray())) + } + + /** + * A key that vanished is not a wrong PIN. Reporting it as one would send + * somebody into a growing backoff typing a PIN that was always correct, + * with no way to find out. + */ + @Test fun `a vanished key is reported as unavailable`() = scope.runTest { + repo.setPin(cheapPin) + macs.deleteKey() + assertEquals(UnlockResult.KeyUnavailable, repo.check(cheapPin)) + } + + /** + * Editing the counter must be the worst move available, not the best one. + * The MAC will not check out, and an unverifiable counter is maximum + * backoff rather than zero. + */ + @Test fun `a tampered lockout counter fails closed`() = scope.runTest { + repo.setPin(cheapPin) + repeat(LockoutPolicy.FREE_ATTEMPTS + 1) { repo.check("1111".toCharArray()) } + + store.edit { it[stringPreferencesKey("lockout_state_v1")] = "v1|0|0|0|0" } + + val outcome = repo.check(cheapPin) + assertTrue("a rewritten counter must not grant an attempt", outcome is UnlockResult.TooSoon) + assertEquals( + LockoutPolicy.MAX_DELAY_MILLIS, + (outcome as UnlockResult.TooSoon).waitMillis, + ) + } + + @Test fun `setting a new PIN clears any running lockout`() = scope.runTest { + repo.setPin(cheapPin) + repeat(LockoutPolicy.FREE_ATTEMPTS + 2) { repo.check("1111".toCharArray()) } + assertTrue(repo.lockoutRemainingMillis() > 0L) + + repo.setPin("1357".toCharArray()) + + assertEquals(0L, repo.lockoutRemainingMillis()) + assertEquals(UnlockResult.Unlocked, repo.check("1357".toCharArray())) + } +} diff --git a/core/security/src/test/kotlin/dev/privacyllc/period/core/security/FakeMacProvider.kt b/core/security/src/test/kotlin/dev/privacyllc/period/core/security/FakeMacProvider.kt new file mode 100644 index 0000000..818f36f --- /dev/null +++ b/core/security/src/test/kotlin/dev/privacyllc/period/core/security/FakeMacProvider.kt @@ -0,0 +1,65 @@ +package dev.privacyllc.period.core.security + +import javax.crypto.Mac +import javax.crypto.spec.SecretKeySpec + +/** + * A [MacProvider] backed by an ordinary in-memory key. + * + * Stands in for the Android Keystore, which has no Robolectric shadow and + * therefore cannot exist on the host JVM at all. Everything the real provider + * contributes to correctness — that the MAC is deterministic, that a different + * key gives a different answer, that a deleted key stops verifying — is + * reproduced here; what it cannot reproduce is the *non-exportability* of the + * key, which is a hardware property and is covered by the instrumented test. + */ +internal class FakeMacProvider( + private var key: ByteArray? = null, + private val seed: Byte = 0x7F, +) : MacProvider { + + var ensureCalls = 0 + private set + var deleteCalls = 0 + private set + + override fun hasKey(): Boolean = key != null + + override fun ensureKey() { + ensureCalls++ + if (key == null) key = ByteArray(32) { (it + seed).toByte() } + } + + override fun mac(data: ByteArray): ByteArray { + val k = key ?: throw IllegalStateException("the verifier key is absent") + return Mac.getInstance("HmacSHA256") + .apply { init(SecretKeySpec(k, "HmacSHA256")) } + .doFinal(data) + } + + override fun deleteKey() { + deleteCalls++ + key = null + } +} + +/** Time that only moves when a test moves it. */ +internal class FakeClocks( + var wall: Long = 1_700_000_000_000L, + var elapsed: Long = 10_000L, +) : Clocks { + override fun wallMillis(): Long = wall + override fun elapsedMillis(): Long = elapsed + + /** Both clocks forward together, as real time does. */ + fun advance(millis: Long) { + wall += millis + elapsed += millis + } + + /** What a reboot looks like: elapsed restarts near zero, wall carries on. */ + fun reboot(wallGain: Long = 60_000L) { + wall += wallGain + elapsed = 500L + } +} diff --git a/core/security/src/test/kotlin/dev/privacyllc/period/core/security/LockoutPolicyTest.kt b/core/security/src/test/kotlin/dev/privacyllc/period/core/security/LockoutPolicyTest.kt new file mode 100644 index 0000000..18c649d --- /dev/null +++ b/core/security/src/test/kotlin/dev/privacyllc/period/core/security/LockoutPolicyTest.kt @@ -0,0 +1,131 @@ +package dev.privacyllc.period.core.security + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Wrong PINs cost time and never cost data. + * + * The two bypasses each get a test, because both are things a person can + * actually do with no tools: change the device clock, and restart the phone. + */ +class LockoutPolicyTest { + + private fun fail(times: Int, clocks: FakeClocks): LockoutState { + var s = LockoutState() + repeat(times) { s = LockoutPolicy.onFailure(s, clocks) } + return s + } + + @Test fun `a mistyped digit costs nothing`() { + val clocks = FakeClocks() + val s = fail(LockoutPolicy.FREE_ATTEMPTS, clocks) + assertEquals(0L, LockoutPolicy.remainingMillis(s, clocks)) + assertFalse(LockoutPolicy.isLockedOut(s, clocks)) + } + + @Test fun `the delay escalates and then stops escalating`() { + val clocks = FakeClocks() + val seen = (1..10).map { LockoutPolicy.delayFor(LockoutPolicy.FREE_ATTEMPTS + it) } + assertEquals(LockoutPolicy.DELAYS_MILLIS, seen.take(LockoutPolicy.DELAYS_MILLIS.size)) + assertTrue( + "every failure past the table stays at the cap, forever", + seen.drop(LockoutPolicy.DELAYS_MILLIS.size).all { it == LockoutPolicy.MAX_DELAY_MILLIS }, + ) + assertEquals(900_000L, LockoutPolicy.MAX_DELAY_MILLIS) + } + + @Test fun `waiting it out clears it`() { + val clocks = FakeClocks() + val s = fail(LockoutPolicy.FREE_ATTEMPTS + 1, clocks) + assertTrue(LockoutPolicy.isLockedOut(s, clocks)) + + clocks.advance(30_000L) + + assertEquals(0L, LockoutPolicy.remainingMillis(s, clocks)) + } + + @Test fun `a correct PIN forgets every previous failure`() { + val clocks = FakeClocks() + fail(9, clocks) + assertEquals(LockoutState(), LockoutPolicy.onSuccess()) + assertEquals(0L, LockoutPolicy.remainingMillis(LockoutPolicy.onSuccess(), clocks)) + } + + /** Bypass one: Settings, move the date forward, come back. */ + @Test fun `moving the device clock forward does not end a lockout`() { + val clocks = FakeClocks() + val s = fail(LockoutPolicy.FREE_ATTEMPTS + 5, clocks) + + clocks.wall += 10 * 24 * 60 * 60 * 1000L // ten days, wall only + + assertTrue( + "the monotonic clock still holds the deadline", + LockoutPolicy.remainingMillis(s, clocks) > 0L, + ) + } + + /** + * The mirror of the above, and the reason the wait is capped: moving the + * clock *backwards* must not turn a fifteen-minute wait into a year. + */ + @Test fun `moving the device clock backward cannot extend a lockout beyond what was earned`() { + val clocks = FakeClocks() + val s = fail(LockoutPolicy.FREE_ATTEMPTS + 5, clocks) + + clocks.wall -= 365L * 24 * 60 * 60 * 1000L + + assertTrue(LockoutPolicy.remainingMillis(s, clocks) <= LockoutPolicy.MAX_DELAY_MILLIS) + } + + /** Bypass two: elapsedRealtime restarts at zero, so the deadline looks passed. */ + @Test fun `rebooting re-applies the full delay rather than clearing it`() { + val clocks = FakeClocks() + val s = fail(LockoutPolicy.FREE_ATTEMPTS + 5, clocks) + + clocks.reboot() + + assertEquals( + "a reboot must cost the full earned delay, not zero", + LockoutPolicy.MAX_DELAY_MILLIS, + LockoutPolicy.remainingMillis(s, clocks), + ) + } + + /** + * Deleting or editing the counter must be the worst move available, not the + * best one. `null` here stands for both "file gone" and "MAC did not check + * out", which is how [AppLockRepository] reports tampering. + */ + @Test fun `a missing or tampered counter is maximum backoff and never zero`() { + assertEquals(LockoutPolicy.MAX_DELAY_MILLIS, LockoutPolicy.remainingMillis(null, FakeClocks())) + assertTrue(LockoutPolicy.isLockedOut(null, FakeClocks())) + } + + /** There is no attempt limit and nothing that erases. A thousand wrong guesses is still just a wait. */ + @Test fun `a thousand wrong attempts still only costs time`() { + val clocks = FakeClocks() + val s = fail(1_000, clocks) + assertEquals(1_000, s.failedAttempts) + assertEquals(LockoutPolicy.MAX_DELAY_MILLIS, LockoutPolicy.remainingMillis(s, clocks)) + clocks.advance(LockoutPolicy.MAX_DELAY_MILLIS) + assertEquals( + "after the wait, the user may try again — there is no terminal state", + 0L, + LockoutPolicy.remainingMillis(s, clocks), + ) + } + + @Test fun `the state survives a round trip through its stored form`() { + val clocks = FakeClocks() + val s = fail(6, clocks) + assertEquals(s, LockoutState.decode(s.encode())) + } + + @Test fun `an unparseable stored form decodes to null rather than throwing`() { + listOf(null, "", "nonsense", "v9|1|2|3|4", "v1|1|2|3", "v1|x|2|3|4", "v1|-1|2|3|4") + .forEach { assertEquals("input: $it", null, LockoutState.decode(it)) } + } +} diff --git a/core/security/src/test/kotlin/dev/privacyllc/period/core/security/PinVerifierTest.kt b/core/security/src/test/kotlin/dev/privacyllc/period/core/security/PinVerifierTest.kt new file mode 100644 index 0000000..794add2 --- /dev/null +++ b/core/security/src/test/kotlin/dev/privacyllc/period/core/security/PinVerifierTest.kt @@ -0,0 +1,126 @@ +package dev.privacyllc.period.core.security + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * The PIN is never stored, and the three outcomes are three for a reason. + * + * These run on the JVM in milliseconds because [MacProvider] is the only part + * that needs hardware. The iteration counts here are deliberately tiny — the + * cost of PBKDF2 is a product decision measured on a device, not something a + * unit test should pay for on every run. + */ +class PinVerifierTest { + + private val cheap = 1_000 + private fun pin(s: String) = s.toCharArray() + + private fun verifier(macs: MacProvider = FakeMacProvider()) = PinVerifier(macs) + + @Test fun `the correct PIN verifies`() { + val v = verifier() + val record = v.enroll(pin("246813"), cheap) + assertTrue(v.verify(pin("246813"), record) is PinCheck.Correct) + } + + @Test fun `a wrong PIN does not`() { + val v = verifier() + val record = v.enroll(pin("246813"), cheap) + assertEquals(PinCheck.Wrong, v.verify(pin("246814"), record)) + } + + /** The point of the whole design: what is written down is not the PIN. */ + @Test fun `the stored record contains neither the PIN nor anything derived from it alone`() { + val record = verifier().enroll(pin("246813"), cheap) + val encoded = record.encode() + assertFalse("the PIN itself is in the record", encoded.contains("246813")) + // A record made with the same PIN and a different salt must differ, or + // the salt is not doing its job and two users with the same PIN would + // store the same bytes. + val again = verifier().enroll(pin("246813"), cheap) + assertFalse("two enrolments produced identical records", record.mac.contentEquals(again.mac)) + } + + /** + * Without the Keystore key the record is unusable — which is the property + * that makes a six-digit PIN safe, since a million candidates is nothing to + * an attacker who can compute the hash themselves. + */ + @Test fun `a different key cannot verify a record made with another`() { + val recorded = PinVerifier(FakeMacProvider(seed = 0x11)).enroll(pin("246813"), cheap) + + // The second key must actually exist, or this asserts KeyUnavailable — + // a different and much weaker claim than "the wrong key gives the wrong + // answer", which is the property being tested. + val otherMacs = FakeMacProvider(seed = 0x22).apply { ensureKey() } + + assertEquals(PinCheck.Wrong, PinVerifier(otherMacs).verify(pin("246813"), recorded)) + } + + /** + * The cruellest failure this feature could produce, and why it is its own + * branch: a missing key means no PIN can ever match, and calling that + * "wrong" would send someone into a growing backoff typing a PIN that was + * always correct. + */ + @Test fun `a missing key is reported as unavailable and never as a wrong PIN`() { + val macs = FakeMacProvider() + val v = PinVerifier(macs) + val record = v.enroll(pin("246813"), cheap) + + macs.deleteKey() + + assertEquals(PinCheck.KeyUnavailable, v.verify(pin("246813"), record)) + } + + @Test fun `enrolling creates the key exactly once`() { + val macs = FakeMacProvider() + val v = PinVerifier(macs) + v.enroll(pin("111111"), cheap) + v.enroll(pin("222222"), cheap) + assertEquals(2, macs.ensureCalls) + assertTrue("ensureKey must be idempotent, not re-minting", macs.hasKey()) + } + + /** + * Raising the cost is only possible in the instant after a correct PIN, + * because that is the only time the PIN exists in memory. + */ + @Test fun `a cheaper record is re-derived on the next successful unlock`() { + val v = verifier() + val old = v.enroll(pin("246813"), cheap) + + val outcome = v.verify(pin("246813"), old) as PinCheck.Correct + + assertNotNull("a below-target record should be rehashed", outcome.rehashed) + assertEquals(PinVerifier.TARGET_ITERATIONS, outcome.rehashed!!.iterations) + assertTrue("the new record must still verify", v.verify(pin("246813"), outcome.rehashed!!) is PinCheck.Correct) + } + + @Test fun `a record already at target cost is not re-derived`() { + val v = verifier() + val current = v.enroll(pin("246813"), PinVerifier.TARGET_ITERATIONS) + val outcome = v.verify(pin("246813"), current) as PinCheck.Correct + assertNull(outcome.rehashed) + } + + /** + * The same Keystore key MACs the lockout counter under tag 0x02. Without + * the leading tag, a value produced in one context could be presented as + * the other. + */ + @Test fun `the verifier tag differs from the backoff tag`() { + assertFalse(PinVerifier.TAG_VERIFIER == PinVerifier.TAG_BACKOFF) + + val macs = FakeMacProvider().apply { ensureKey() } + val payload = ByteArray(8) { it.toByte() } + val asVerifier = macs.mac(byteArrayOf(PinVerifier.TAG_VERIFIER) + payload) + val asBackoff = macs.mac(byteArrayOf(PinVerifier.TAG_BACKOFF) + payload) + assertFalse("the same bytes MAC identically under both tags", asVerifier.contentEquals(asBackoff)) + } +} diff --git a/docs/architecture/README.md b/docs/architecture/README.md index 32bfcd2..d8f7fdf 100644 --- a/docs/architecture/README.md +++ b/docs/architecture/README.md @@ -31,7 +31,7 @@ function calls. Nothing below the ViewModel knows Compose exists. ## Modules -Eight today — a module created before it has contents is a place +Nine today — a module created before it has contents is a place for things to be put by accident. The wider layout sketched in [`../planning/PRODUCT_PLAN.md` §9](../planning/PRODUCT_PLAN.md) arrives the same way, with the batch that needs it. @@ -44,6 +44,7 @@ way, with the batch that needs it. | `core/datastore` | Android library | `UserPreferences` and the settings that are not health history | nothing in this project | | `core/data` | Android library | `CycleRepository`, entity⇄domain mapping, accuracy — the only module that touches a DAO | `core/database`, `domain/cycle`, `domain/prediction` | | `core/notifications` | Android library | reminder copy, the privacy modes, WorkManager scheduling | `core/data`, `core/datastore`, `domain/*` | +| `core/security` | Android library | the app lock's PIN verifier, its Keystore key and the lockout policy | **nothing in this project** | | `domain/cycle` | **Kotlin JVM** | `PeriodRecord`, `SpottingRecord`, `CycleRecord` and the rules over them | nothing | | `domain/prediction` | **Kotlin JVM** | the forecast, the window, confidence, `NotYetObservation` | `domain/cycle` | @@ -88,6 +89,30 @@ silent data loss on update — here, a user's entire cycle history gone with no error and no way back. A missing migration must be a crash in testing rather than a wipe in production. +### Why `core/security` depends on nothing + +It holds key material, and the rule that follows from that is the one worth +writing down: **it must never be able to see a cycle date.** So its allowed +dependency set is empty, and the erase that a forgotten PIN leads to is +orchestrated in `app` — `LockEraseViewModel` calls the cycle repository and the +lock repository in turn, rather than `core/security` reaching for either. + +Adding it cost three rows in the root `build.gradle.kts`, and only two of them +fail loudly if forgotten: + +| Row | What it does | What happens if forgotten | +| --- | --- | --- | +| `":core:security" to emptySet()` in `allowedProjectDependencies` | declares its permitted edges | build fails — a module with no entry is reported as never checked | +| `":core:security"` in the `":app"` set | lets `app` depend on it | build fails on the dependency | +| `"core/security"` in `modulesSeeingHealthData` | puts it under `checkNoHealthLogging` | **nothing** — it is silently never scanned | + +The third is the one that matters most and warns least, which is exactly the +hazard `app/proguard-rules.pro` already describes: *"Somebody adding a module and +forgetting to list it gets no warning, because absence of a finding looks exactly +like a clean result."* In this module a stray `println` would print key material. +The count in the guard's own output is the check: it reports how many files it +scanned, and that number went from 52 to 62 when this module was added. + ### Ordinary outcomes are values; only faults are exceptions `period_records.startDate` is UNIQUE and inserts ABORT rather than REPLACE, so a diff --git a/docs/design/README.md b/docs/design/README.md index beb2d65..7639dd8 100644 --- a/docs/design/README.md +++ b/docs/design/README.md @@ -143,6 +143,36 @@ rule [`../data/README.md`](../data/README.md) applies to placeholder images, for the same reason: something that looks finished outlives the issue that would have replaced it. +## The lock screen is the one semi-public surface + +Everything else in this app is seen only by somebody who already has it open. +The lock screen is different: it is what appears when the owner opens the app in +front of somebody else, and what anybody who picks the phone up sees. Three +consequences, and none of them is a style preference. + +**It names nothing.** No "period", no "cycle", no "fertility" — the same list +the notification copy lives under, and `LockCopyTest` checks it against exactly +that list. The strings live in `LockCopy` rather than inline for a reason worth +repeating: a source-grep guard over the screen file cannot work, because the +KDoc explaining the rule contains every word it forbids. + +**It has no illustration.** The temptation is a padlock, and this document +already says why not: it "would read as a security product rather than a calm +one". The lock screen is where that pull is strongest and where giving in would +do the most damage — a padlock over a phone reads as *nobody can get in*, and +what this lock actually does is narrower. + +**It says what the lock does not do.** The setup flow, before the first digit, +states that the lock stops the app opening and does **not** encrypt what is +recorded. That is an unusual thing for a product to volunteer, and it is the +honest version: `SECURITY.md` lists a rooted device and someone who unlocks the +phone as out of scope, so a screen implying otherwise would be this product's one +dishonest moment. + +Three screens, all with a `fontScale = 2.0f` preview beside the light and dark +pair — the first in this repo to carry one, after a 2.0-scale defect shipped in +the navigation bar. + ## The states most often left undesigned Designed here on purpose, because they are the two most people meet first: diff --git a/docs/security/SECURITY.md b/docs/security/SECURITY.md index b5d0df0..a45687d 100644 --- a/docs/security/SECURITY.md +++ b/docs/security/SECURITY.md @@ -54,10 +54,44 @@ Data Safety section, and never lets health data reach any of them. breached from a server we do not run. - **No account is required** for core tracking, so there is no identity to correlate the history with. -- Biometric/PIN gating is **Batch 06 and not built** — nothing gates app launch - today, and `UserPreferences.biometricLockEnabled` in `core/datastore` is a - persisted flag that nothing outside that module acts on yet. When it lands, any - secret backing it is Android Keystore-backed — never a value in DataStore. +- **The app lock is built, and it is a gate rather than encryption.** With a PIN + set, nothing composes before it is entered — the gate wraps the whole + composition rather than being a screen inside it, because every tab starts + reading history the moment it composes. What it defends is the app being + opened by somebody who has the phone. It does **not** encrypt the records; see + *Deliberately out of scope*, and the setup screen says so in those words + before the first digit is typed. +- **The PIN is never stored, and the device PIN is never accepted.** What is + written down is `HMAC(keystoreKey, 0x01 || salt || PBKDF2(pin, salt, 210k))` — + a MAC taken with a key that cannot leave the Android Keystore, which is what + makes a six-digit PIN safe: a million candidates is nothing to an attacker who + can compute the hash themselves, and impossible for one who cannot get the key + off the device. The biometric prompt requests `BIOMETRIC_STRONG` only and never + `DEVICE_CREDENTIAL`, because in this threat model a partner very often knows + the phone's own PIN, and accepting it would make this lock exactly as strong as + the lock it sits behind. +- **The verifier key is deliberately not bound to user authentication**, and the + KDoc on `AndroidKeyStoreMacProvider` lists every builder call omitted to keep + it that way. A key bound to the device credential dies when the passcode + changes or the screen lock is removed — and with no recovery path, that is + somebody's entire history destroyed by an unrelated action. The *biometric* + key is separate and takes the opposite policy, where invalidation correctly + degrades to "use your PIN" rather than to lockout. +- **Wrong PINs cost time and never cost data.** Four free attempts, then + 30s → 1m → 2m → 5m → 15m, capped at fifteen minutes forever. There is no + attempt limit and nothing is ever erased automatically: an auto-wipe would let + a partner, or a child, or a pocket, destroy a history permanently while knowing + nothing. Both clock bypasses are closed — the wait is the longer of a wall-clock + and a monotonic deadline, and a reboot re-applies it in full. +- **A forgotten PIN is not recoverable, and that is a decision** (tracker #34). + The only route past the lock screen erases everything and grants access to + nothing. It also clears the Keystore key, without which a user would have + erased their history and still be locked out. +- **`USE_BIOMETRIC` and `USE_FINGERPRINT`** now appear in the merged manifest. + Neither is typed by this project; both arrive with `androidx.biometric`, and + `checkPermissions` failed the build until they were allowed on purpose. + `USE_FINGERPRINT` looks removable and is not — it is the pre-API-28 path, which + `minSdk 26` admits. - **Platform backup is reviewed before the health database is allowed into it.** An Android auto-backup that silently ships the cycle database to a cloud account defeats the entire local-first argument, and it is on by default. @@ -214,9 +248,11 @@ Written down so an unknown gap becomes a known one: the key is not the same as losing everything. - **Forensic recovery of deleted rows.** Delete My Data removes the data through the database; it does not overwrite flash. -- **Someone who knows the unlock PIN.** Biometric/PIN gating raises the bar over - an unlocked phone; it does not defend against a person the user has given - access to. The incognito launcher option ([§32](../planning/PRODUCT_PLAN.md), +- **Someone who knows the app's PIN.** The lock raises the bar over an unlocked + phone; it does not defend against a person the user has given access to. Note + the app's PIN is deliberately *not* the device's — the prompt never accepts the + device credential — so knowing how to unlock the phone is not knowing how to + open this. The incognito launcher option ([§32](../planning/PRODUCT_PLAN.md), not built yet) is the answer to the adjacent problem — what the app *looks* like on a shared home screen. - **Network-level observation of ad traffic.** It carries no health data, which diff --git a/docs/security/SECURITY_CHECKLIST.md b/docs/security/SECURITY_CHECKLIST.md index eac75b7..da432a7 100644 --- a/docs/security/SECURITY_CHECKLIST.md +++ b/docs/security/SECURITY_CHECKLIST.md @@ -51,6 +51,11 @@ The one group that is not generic. Every item proves part of ### What the device and the lock screen expose - [ ] Cycle history is in app-private storage only — proves nothing landed in shared or external storage +- [ ] With the app lock on, the recents card is a solid colour and `adb exec-out screencap` returns a black frame — proves `FLAG_SECURE` is actually applied, which no source check can establish +- [ ] Killed with `adb shell am kill` and reopened from recents, the app lands on the lock screen — proves the unlock flag is not in saved state, which is the one bug that would make the lock look fine and never engage +- [ ] A reminder action tapped while locked writes nothing until after the unlock — proves a bystander cannot record an answer in someone's history from the phone's own lock screen +- [ ] `KeystoreVerifierTest` run on `PeriodMinSdk26` as well as a current image — proves `PBKDF2WithHmacSHA256` and a non-auth-bound Keystore key exist at the oldest supported version, which is the one algorithm choice here with no margin +- [ ] The erase behind "Forgot your PIN?" leaves the app openable — proves the Keystore key went with the records, and the user is not erased *and* still locked out - [ ] No shipped string, illustration, screenshot or store listing says the cycle database is **encrypted** — proves the product does not claim a control [`SECURITY.md`](SECURITY.md) records as deliberately out of scope. It is on this list because the claim is attractive, easy to draw, and was already sitting in an artwork brief before anybody checked whether it was true - [ ] Lock-screen text in **Discreet** and **Maximum privacy** modes contains no menstrual detail, checked on a real lock screen at every mode — proves the notification privacy feature actually works, which is the breach most likely to happen - [ ] The health database's inclusion in platform auto-backup is a **recorded decision**, not a default — proves the local-first promise is not undone by the OS diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index bc672a6..f7d2fa9 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -23,6 +23,17 @@ androidxTestCore = "1.7.0" testRunner = "1.7.0" datastore = "1.2.1" work = "2.11.2" +# 1.1.0 is the newest STABLE androidx.biometric, confirmed against +# dl.google.com/dl/android/maven2/androidx/biometric/group-index.xml on +# 2026-08-19: 1.4.0 is alpha only, biometric-ktx has never had a stable +# release, and biometric-compose is alpha. A privacy app does not ship an +# alpha in the one component that decides whether the app opens. +biometric = "1.1.0" +# Pinned, not inherited. androidx.biometric 1.1.0 asks for fragment 1.2.5 and +# the graph settles on 1.5.1 — a 2022 library that would become MainActivity's +# base class, beside activity 1.13.0 and lifecycle 2.11.0. 1.9.0 is the current +# stable, checked the same day and the same way as biometric above. +fragment = "1.9.0" [libraries] androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } @@ -55,6 +66,8 @@ androidx-room-compiler = { group = "androidx.room", name = "room-compiler", vers androidx-room-testing = { group = "androidx.room", name = "room-testing", version.ref = "room" } androidx-work-runtime = { group = "androidx.work", name = "work-runtime-ktx", version.ref = "work" } androidx-datastore-preferences = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "datastore" } +androidx-biometric = { group = "androidx.biometric", name = "biometric", version.ref = "biometric" } +androidx-fragment = { group = "androidx.fragment", name = "fragment", version.ref = "fragment" } androidx-sqlite-bundled = { group = "androidx.sqlite", name = "sqlite-bundled", version.ref = "sqlite" } robolectric = { group = "org.robolectric", name = "robolectric", version.ref = "robolectric" } diff --git a/settings.gradle.kts b/settings.gradle.kts index d04a226..ce23c31 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -30,5 +30,6 @@ include(":core:database") include(":core:datastore") include(":core:data") include(":core:notifications") +include(":core:security") include(":domain:cycle") include(":domain:prediction")