diff --git a/app/build.gradle.kts b/app/build.gradle.kts index c5d49e7fc..376955630 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -140,6 +140,7 @@ dependencies { implementation(projects.feature.settings) implementation(projects.feature.backup) implementation(projects.feature.onboarding) + implementation(projects.feature.passwordHealth) implementation(projects.legacyMigration) implementation(libs.androidx.core.splashscreen) diff --git a/app/src/main/kotlin/de/davis/keygo/app/di/AppModule.kt b/app/src/main/kotlin/de/davis/keygo/app/di/AppModule.kt index 3ad2db030..4193c6a42 100644 --- a/app/src/main/kotlin/de/davis/keygo/app/di/AppModule.kt +++ b/app/src/main/kotlin/de/davis/keygo/app/di/AppModule.kt @@ -1,9 +1,12 @@ package de.davis.keygo.app.di +import android.content.Context +import androidx.work.WorkManager import de.davis.keygo.dashboard.di.DashboardModule import org.koin.core.annotation.ComponentScan import org.koin.core.annotation.Configuration import org.koin.core.annotation.Module +import org.koin.core.annotation.Single @Module( includes = [ @@ -12,6 +15,11 @@ import org.koin.core.annotation.Module ) @ComponentScan("de.davis.keygo.app") @Configuration -object AppModule +object AppModule { + + @Single + internal fun provideWorkManager(context: Context): WorkManager = + WorkManager.getInstance(context) +} diff --git a/app/src/main/kotlin/de/davis/keygo/app/presentation/AppDestinations.kt b/app/src/main/kotlin/de/davis/keygo/app/presentation/AppDestinations.kt index fab65b7c3..b2a07fb38 100644 --- a/app/src/main/kotlin/de/davis/keygo/app/presentation/AppDestinations.kt +++ b/app/src/main/kotlin/de/davis/keygo/app/presentation/AppDestinations.kt @@ -3,12 +3,15 @@ package de.davis.keygo.app.presentation import androidx.annotation.StringRes import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Cast +import androidx.compose.material.icons.filled.HealthAndSafety import androidx.compose.material.icons.filled.Home import androidx.compose.material.icons.filled.Settings import androidx.compose.ui.graphics.vector.ImageVector import androidx.navigation3.runtime.NavKey import de.davis.keygo.R import de.davis.keygo.core.presentation.model.RouteDestination +import de.davis.keygo.feature.password_health.presentation.ACTION_OPEN_PASSWORD_HEALTH +import de.davis.keygo.feature.password_health.presentation.PasswordHealthRoute import de.davis.keygo.feature.settings.presentation.SettingsRoute /** The navigation bar's destinations. */ @@ -16,9 +19,17 @@ enum class AppDestinations( val route: NavKey, @StringRes val label: Int, val icon: ImageVector, - @StringRes val contentDescription: Int + @StringRes val contentDescription: Int, + val intentAction: String? = null, ) { HOME(RouteDestination.Home, R.string.home, Icons.Default.Home, R.string.home), + PASSWORD_HEALTH( + PasswordHealthRoute, + R.string.password_health, + Icons.Default.HealthAndSafety, + R.string.password_health, + ACTION_OPEN_PASSWORD_HEALTH, + ), CONNECTIVITY( RouteDestination.Connectivity, R.string.connectivity, @@ -31,6 +42,12 @@ enum class AppDestinations( Icons.Default.Settings, R.string.settings ), + ; + + companion object { + fun fromIntentAction(action: String?): AppDestinations? = + action?.let { entries.firstOrNull { it.intentAction == action } } + } } val TopLevelRoutes: Set = AppDestinations.entries.mapTo(LinkedHashSet()) { it.route } diff --git a/app/src/main/kotlin/de/davis/keygo/app/presentation/AppViewModel.kt b/app/src/main/kotlin/de/davis/keygo/app/presentation/AppViewModel.kt index f7ef1770f..2cb69f427 100644 --- a/app/src/main/kotlin/de/davis/keygo/app/presentation/AppViewModel.kt +++ b/app/src/main/kotlin/de/davis/keygo/app/presentation/AppViewModel.kt @@ -2,8 +2,10 @@ package de.davis.keygo.app.presentation import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import androidx.navigation3.runtime.NavKey import de.davis.keygo.core.identity.domain.repository.AccountRepository import de.davis.keygo.core.security.domain.Session +import de.davis.keygo.feature.password_health.domain.PasswordHealthAttention import de.davis.keygo.legacy_migration.domain.usecase.HasMainPasswordUseCase import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -17,6 +19,7 @@ internal class AppViewModel( private val accountRepository: AccountRepository, private val hasV1Password: HasMainPasswordUseCase, session: Session, + passwordHealthAttention: PasswordHealthAttention, ) : ViewModel() { private val _isReturningUser = MutableStateFlow(null) @@ -28,9 +31,23 @@ internal class AppViewModel( */ val isSessionActive: StateFlow = session.isActive + val needsAttentionCount: StateFlow = passwordHealthAttention.needsAttention + + private val _requestedTopLevelRoute = MutableStateFlow(null) + val requestedTopLevelRoute = _requestedTopLevelRoute.asStateFlow() + init { viewModelScope.launch { _isReturningUser.update { accountRepository.getOrNull() != null || hasV1Password() } } } + + fun onIntentAction(action: String?) { + AppDestinations.fromIntentAction(action) + ?.let { dest -> _requestedTopLevelRoute.update { dest.route } } + } + + fun onTopLevelRouteRequestHandled() { + _requestedTopLevelRoute.value = null + } } diff --git a/app/src/main/kotlin/de/davis/keygo/app/presentation/MainActivity.kt b/app/src/main/kotlin/de/davis/keygo/app/presentation/MainActivity.kt index 9005b6209..4850e9cd8 100644 --- a/app/src/main/kotlin/de/davis/keygo/app/presentation/MainActivity.kt +++ b/app/src/main/kotlin/de/davis/keygo/app/presentation/MainActivity.kt @@ -52,6 +52,9 @@ class MainActivity : FragmentActivity() { super.onCreate(savedInstanceState) + // A recreated Activity already restored the tab the request selected. + if (savedInstanceState == null) viewModel.onIntentAction(intent.action) + splashScreen.setKeepOnScreenCondition { viewModel.isReturningUser.value == null } @@ -61,6 +64,8 @@ class MainActivity : FragmentActivity() { // Null until the account has been looked up, which the splash screen waits out. val hasAccess = viewModel.isReturningUser.collectAsState().value ?: return@setContent val isSessionActive by viewModel.isSessionActive.collectAsState() + val needsAttentionCount by viewModel.needsAttentionCount.collectAsState() + val requestedTopLevelRoute by viewModel.requestedTopLevelRoute.collectAsState() KeyGoTheme { val snackbarManager = koinInject() @@ -71,12 +76,20 @@ class MainActivity : FragmentActivity() { hasAccess = hasAccess, launchRoute = launchRoute(hasAccess), isSessionActive = isSessionActive, + needsAttentionCount = needsAttentionCount, + requestedTopLevelRoute = requestedTopLevelRoute, + onTopLevelRouteRequestHandled = viewModel::onTopLevelRouteRequestHandled, ) } } } } + override fun onNewIntent(intent: Intent) { + super.onNewIntent(intent) + viewModel.onIntentAction(intent.action) + } + private fun launchRoute(hasAccess: Boolean): NavKey = intent.totpImportRedirect() ?: if (hasAccess) AuthRoute() else OnboardingRoute() } @@ -93,6 +106,9 @@ private fun App( hasAccess: Boolean, launchRoute: NavKey, isSessionActive: Boolean, + needsAttentionCount: Int, + requestedTopLevelRoute: NavKey?, + onTopLevelRouteRequestHandled: () -> Unit, ) { val navigationState = rememberAppNavigationState( launchRoute = launchRoute, @@ -103,6 +119,12 @@ private fun App( LockAppWhenSessionEnds(isSessionActive, navigator) + LaunchedEffect(requestedTopLevelRoute) { + if (requestedTopLevelRoute == null) return@LaunchedEffect + navigator.selectBehindOverlay(requestedTopLevelRoute) + onTopLevelRouteRequestHandled() + } + val windowAdaptiveInfo = currentWindowAdaptiveInfoV2() val directive = remember(windowAdaptiveInfo) { calculatePaneScaffoldDirective(windowAdaptiveInfo) @@ -130,6 +152,7 @@ private fun App( onItemSelected = { type -> navigator.showDetail(RouteDestination.CreateItem(type)) }, showChrome = shell.showNavigation, showPrimaryActionButton = shell.showCreateButton, + needsAttentionCount = needsAttentionCount, snackbarHost = { SnackbarHost(hostState = snackbarHostState) }, diff --git a/app/src/main/kotlin/de/davis/keygo/app/presentation/component/NavigationWrapper.kt b/app/src/main/kotlin/de/davis/keygo/app/presentation/component/NavigationWrapper.kt index b9f9478b5..31735b36c 100644 --- a/app/src/main/kotlin/de/davis/keygo/app/presentation/component/NavigationWrapper.kt +++ b/app/src/main/kotlin/de/davis/keygo/app/presentation/component/NavigationWrapper.kt @@ -21,6 +21,8 @@ import androidx.compose.material.icons.automirrored.filled.MenuOpen import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.outlined.Menu +import androidx.compose.material3.Badge +import androidx.compose.material3.BadgedBox import androidx.compose.material3.DrawerDefaults import androidx.compose.material3.DrawerValue import androidx.compose.material3.ExperimentalMaterial3Api @@ -84,7 +86,9 @@ import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalWindowInfo +import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.traversalIndex import androidx.compose.ui.text.style.TextAlign @@ -100,6 +104,7 @@ import de.davis.keygo.core.ui.composition.LocalNavigationBarCollapseState import de.davis.keygo.core.ui.composition.NavigationBarCollapseState import kotlinx.coroutines.launch import de.davis.keygo.core.ui.R as CoreUiR +import de.davis.keygo.feature.password_health.R as PasswordHealthR @OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class) @@ -111,6 +116,7 @@ fun KeyGoNavigationWrapper( onItemSelected: (VaultItemType) -> Unit, showChrome: Boolean = true, showPrimaryActionButton: Boolean = true, + needsAttentionCount: Int = 0, containerColor: Color = NavigationSuiteScaffoldDefaults.containerColor, contentColor: Color = NavigationSuiteScaffoldDefaults.contentColor, buttonContainerColor: Color = MaterialTheme.colorScheme.tertiaryContainer, @@ -170,6 +176,7 @@ fun KeyGoNavigationWrapper( drawerState.close() } }, + needsAttentionCount = needsAttentionCount, buttonContainerColor = buttonContainerColor, buttonContentColor = buttonContentColor ) @@ -191,6 +198,7 @@ fun KeyGoNavigationWrapper( drawerState.open() } }, + needsAttentionCount = needsAttentionCount, buttonContainerColor = buttonContainerColor, buttonContentColor = buttonContentColor, ) @@ -331,6 +339,7 @@ fun KeyGoNavigationSuite( navigateToTopLvlDestination: (NavKey) -> Unit, onButtonClicked: () -> Unit, onOpenDrawer: () -> Unit, + needsAttentionCount: Int = 0, buttonContainerColor: Color = FloatingActionButtonDefaults.containerColor, buttonContentColor: Color = contentColorFor(buttonContainerColor), ) { @@ -339,6 +348,7 @@ fun KeyGoNavigationSuite( KeyGoNavigationBar( selectedRoute = selectedRoute, navigateToTopLvlDestination = navigateToTopLvlDestination, + needsAttentionCount = needsAttentionCount, ) } @@ -348,6 +358,7 @@ fun KeyGoNavigationSuite( navigateToTopLvlDestination = navigateToTopLvlDestination, onButtonClicked = onButtonClicked, onOpenDrawer = onOpenDrawer, + needsAttentionCount = needsAttentionCount, buttonContainerColor = buttonContainerColor, buttonContentColor = buttonContentColor ) @@ -358,6 +369,7 @@ fun KeyGoNavigationSuite( selectedRoute = selectedRoute, onButtonClicked = onButtonClicked, navigateToTopLvlDestination = navigateToTopLvlDestination, + needsAttentionCount = needsAttentionCount, buttonContainerColor = buttonContainerColor, buttonContentColor = buttonContentColor ) @@ -371,18 +383,14 @@ fun KeyGoNavigationSuite( fun KeyGoNavigationBar( selectedRoute: NavKey?, navigateToTopLvlDestination: (NavKey) -> Unit, + needsAttentionCount: Int = 0, ) { NavigationBar { AppDestinations.entries.forEach { destination -> NavigationBarItem( selected = destination.route == selectedRoute, onClick = { navigateToTopLvlDestination(destination.route) }, - icon = { - Icon( - imageVector = destination.icon, - contentDescription = stringResource(destination.contentDescription) - ) - }, + icon = { DestinationIcon(destination, destination.badge(needsAttentionCount)) }, label = { Text(text = stringResource(destination.label)) }, alwaysShowLabel = false ) @@ -396,6 +404,7 @@ fun KeyGoNavigationRail( navigateToTopLvlDestination: (NavKey) -> Unit, onButtonClicked: () -> Unit, onOpenDrawer: () -> Unit, + needsAttentionCount: Int = 0, buttonContainerColor: Color = FloatingActionButtonDefaults.containerColor, buttonContentColor: Color = contentColorFor(buttonContainerColor) ) { @@ -434,12 +443,7 @@ fun KeyGoNavigationRail( NavigationRailItem( selected = destination.route == selectedRoute, onClick = { navigateToTopLvlDestination(destination.route) }, - icon = { - Icon( - imageVector = destination.icon, - contentDescription = stringResource(destination.contentDescription) - ) - }, + icon = { DestinationIcon(destination, destination.badge(needsAttentionCount)) }, label = { Text(text = stringResource(destination.label)) }, alwaysShowLabel = false ) @@ -453,6 +457,7 @@ fun KeyGoNavigationDrawer( selectedRoute: NavKey?, navigateToTopLvlDestination: (NavKey) -> Unit, onButtonClicked: () -> Unit, + needsAttentionCount: Int = 0, buttonContainerColor: Color = FloatingActionButtonDefaults.containerColor, buttonContentColor: Color = contentColorFor(buttonContainerColor) ) { @@ -463,6 +468,7 @@ fun KeyGoNavigationDrawer( selectedRoute = selectedRoute, navigateToTopLvlDestination = navigateToTopLvlDestination, onButtonClicked = onButtonClicked, + needsAttentionCount = needsAttentionCount, buttonContainerColor = buttonContainerColor, buttonContentColor = buttonContentColor ) @@ -475,6 +481,7 @@ fun DrawerContent( navigateToTopLvlDestination: (NavKey) -> Unit, onButtonClicked: () -> Unit, onCloseDrawer: (() -> Unit)? = null, + needsAttentionCount: Int = 0, buttonContainerColor: Color = FloatingActionButtonDefaults.containerColor, buttonContentColor: Color = contentColorFor(buttonContainerColor) ) { @@ -545,12 +552,44 @@ fun DrawerContent( }, selected = destination.route == selectedRoute, onClick = { navigateToTopLvlDestination(destination.route) }, + badge = destination.badge(needsAttentionCount), ) } } } } +private fun AppDestinations.badge(needsAttentionCount: Int): (@Composable () -> Unit)? = + if (this != AppDestinations.PASSWORD_HEALTH || needsAttentionCount <= 0) null + else ({ AttentionBadge(needsAttentionCount) }) + +@Composable +private fun AttentionBadge(count: Int) { + val description = pluralStringResource( + PasswordHealthR.plurals.password_health_needs_attention, + count, + count, + ) + Badge(modifier = Modifier.semantics { contentDescription = description }) { + Text(text = if (count > MaxBadgeCount) "$MaxBadgeCount+" else count.toString()) + } +} + +@Composable +private fun DestinationIcon(destination: AppDestinations, badge: (@Composable () -> Unit)?) { + val icon = @Composable { + Icon( + imageVector = destination.icon, + contentDescription = stringResource(destination.contentDescription), + ) + } + + if (badge != null) BadgedBox(badge = { badge.invoke() }) { icon() } + else icon() +} + +private const val MaxBadgeCount = 99 + @Composable private fun navigationInsets( layoutType: NavigationSuiteType, diff --git a/app/src/main/kotlin/de/davis/keygo/app/presentation/navigation/AppNavigator.kt b/app/src/main/kotlin/de/davis/keygo/app/presentation/navigation/AppNavigator.kt index 67d1210d8..671d4e70d 100644 --- a/app/src/main/kotlin/de/davis/keygo/app/presentation/navigation/AppNavigator.kt +++ b/app/src/main/kotlin/de/davis/keygo/app/presentation/navigation/AppNavigator.kt @@ -47,6 +47,15 @@ class AppNavigator(val state: AppNavigationState) { else state.currentStack.add(route) } + /** + * Selects the top level [route] for a request from outside the app, such as a notification. + * Not gated: it only changes what sits under the overlay, so a gate stays up and the unlock + * reveals [route]. + */ + fun selectBehindOverlay(route: NavKey) { + if (route in state.backStacks) state.topLevelRoute = route + } + /** * Switches to the top level [route], keeping whatever history it had. Picking the destination * already showing is what clears it, popping back to its base. Nothing sits underneath a base, diff --git a/app/src/main/kotlin/de/davis/keygo/app/presentation/navigation/EntryProvider.kt b/app/src/main/kotlin/de/davis/keygo/app/presentation/navigation/EntryProvider.kt index 5bcb6e0bc..e21484c19 100644 --- a/app/src/main/kotlin/de/davis/keygo/app/presentation/navigation/EntryProvider.kt +++ b/app/src/main/kotlin/de/davis/keygo/app/presentation/navigation/EntryProvider.kt @@ -7,6 +7,8 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Text +import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi +import androidx.compose.material3.adaptive.navigation3.ListDetailSceneStrategy import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment @@ -29,6 +31,8 @@ import de.davis.keygo.feature.item.create.presentation.totp.AssignTotpRoute import de.davis.keygo.feature.item.create.presentation.totp.assignTotpEntries import de.davis.keygo.feature.onboarding.presentation.OnboardingRoute import de.davis.keygo.feature.onboarding.presentation.onboardingEntries +import de.davis.keygo.feature.password_health.presentation.component.PasswordHealthDetailPlaceholder +import de.davis.keygo.feature.password_health.presentation.passwordHealthEntries import de.davis.keygo.feature.settings.presentation.ChangePasswordRoute import de.davis.keygo.feature.settings.presentation.settingsEntries import de.davis.keygo.feature.totp.presentation.SelectItemForTotpRoute @@ -38,6 +42,7 @@ import de.davis.keygo.item.dialog.SelectItemContent private const val TAG = "KeyGoEntryProvider" +@OptIn(ExperimentalMaterial3AdaptiveApi::class) @Composable fun keyGoEntryProvider(navigator: AppNavigator, hasAccess: Boolean): (NavKey) -> NavEntry { val activity = LocalActivity.current @@ -129,6 +134,14 @@ fun keyGoEntryProvider(navigator: AppNavigator, hasAccess: Boolean): (NavKey) -> ) } } + + passwordHealthEntries( + metadata = ListDetailSceneStrategy.listPane( + detailPlaceholder = { PasswordHealthDetailPlaceholder() }, + ) + NavigationOnly, + openItemId = (navigator.state.openDetail as? RouteDestination.ViewItem)?.id, + openItem = { itemId -> navigator.showDetail(RouteDestination.ViewItem(itemId)) }, + ) } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index c9855c7b6..048924ade 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -8,6 +8,7 @@ Add new Element Home + Password Health Connectivity Settings diff --git a/app/src/test/kotlin/de/davis/keygo/app/presentation/AppDestinationsTest.kt b/app/src/test/kotlin/de/davis/keygo/app/presentation/AppDestinationsTest.kt new file mode 100644 index 000000000..f8f1decb9 --- /dev/null +++ b/app/src/test/kotlin/de/davis/keygo/app/presentation/AppDestinationsTest.kt @@ -0,0 +1,27 @@ +package de.davis.keygo.app.presentation + +import de.davis.keygo.feature.password_health.presentation.ACTION_OPEN_PASSWORD_HEALTH +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class AppDestinationsTest { + + @Test + fun `the password health action opens the password health tab`() { + assertEquals( + AppDestinations.PASSWORD_HEALTH, + AppDestinations.fromIntentAction(ACTION_OPEN_PASSWORD_HEALTH), + ) + } + + @Test + fun `the launcher action opens no tab`() { + assertNull(AppDestinations.fromIntentAction("android.intent.action.MAIN")) + } + + @Test + fun `no action opens no tab, even though most tabs have none`() { + assertNull(AppDestinations.fromIntentAction(null)) + } +} diff --git a/app/src/test/kotlin/de/davis/keygo/app/presentation/navigation/AppNavigatorTest.kt b/app/src/test/kotlin/de/davis/keygo/app/presentation/navigation/AppNavigatorTest.kt index e38e2eb3d..0e5c00104 100644 --- a/app/src/test/kotlin/de/davis/keygo/app/presentation/navigation/AppNavigatorTest.kt +++ b/app/src/test/kotlin/de/davis/keygo/app/presentation/navigation/AppNavigatorTest.kt @@ -8,6 +8,7 @@ import de.davis.keygo.core.item.generated.domain.model.VaultItemType import de.davis.keygo.core.presentation.model.RouteDestination import de.davis.keygo.feature.auth.presentation.AuthRoute import de.davis.keygo.feature.onboarding.presentation.OnboardingRoute +import de.davis.keygo.feature.password_health.presentation.PasswordHealthRoute import de.davis.keygo.feature.settings.presentation.ChangePasswordRoute import de.davis.keygo.feature.settings.presentation.SettingsRoute import de.davis.keygo.feature.totp.presentation.SelectItemForTotpRoute @@ -208,6 +209,27 @@ class AppNavigatorTest { ) } + @Test + fun `an item opened from the health report lands beside it, not on the home stack`() { + val navigator = unlocked() + val itemId = newItemId() + navigator.navigate(PasswordHealthRoute) + + navigator.showDetail(RouteDestination.ViewItem(itemId)) + + assertEquals( + listOf(PasswordHealthRoute, RouteDestination.ViewItem(itemId)), + navigator.shown, + ) + assertEquals( + listOf(RouteDestination.Home), + navigator.state.backStacks.getValue(RouteDestination.Home).toList(), + ) + + navigator.goBack() + assertEquals(listOf(PasswordHealthRoute), navigator.shown) + } + @Test fun `editing stacks on the item it edits, so back returns to it`() { val navigator = unlocked() @@ -394,6 +416,40 @@ class AppNavigatorTest { ) } + @Test + fun `a tab selected from outside the app waits behind the gate until the unlock`() { + val navigator = navigator() + + navigator.selectBehindOverlay(PasswordHealthRoute) + + assertEquals(listOf(AuthRoute()), navigator.shown) + + navigator.unlock() + + assertEquals(listOf(PasswordHealthRoute), navigator.shown) + } + + @Test + fun `a tab selected from outside the app keeps the history that tab had`() { + val navigator = unlocked() + navigator.navigate(SettingsRoute) + navigator.navigate(ChangePasswordRoute) + navigator.navigate(RouteDestination.Home) + + navigator.selectBehindOverlay(SettingsRoute) + + assertEquals(listOf(SettingsRoute, ChangePasswordRoute), navigator.shown) + } + + @Test + fun `a route that is not a tab is not selected from outside the app`() { + val navigator = unlocked() + + navigator.selectBehindOverlay(ChangePasswordRoute) + + assertEquals(RouteDestination.Home, navigator.state.topLevelRoute) + } + @Test fun `the chrome still standing behind the gate cannot navigate anywhere`() { // The navigation bar animates out rather than disappearing, so it stays clickable for a @@ -571,6 +627,7 @@ class AppNavigatorTest { private companion object { val TOP_LEVEL_ROUTES: Set = linkedSetOf( RouteDestination.Home, + PasswordHealthRoute, RouteDestination.Connectivity, SettingsRoute, ) diff --git a/core/item/src/main/kotlin/de/davis/keygo/core/item/domain/usecase/ObserveAllTagsSortedUseCase.kt b/core/item/src/main/kotlin/de/davis/keygo/core/item/domain/usecase/ObserveAllTagsSortedUseCase.kt index ae2f88b88..593f53075 100644 --- a/core/item/src/main/kotlin/de/davis/keygo/core/item/domain/usecase/ObserveAllTagsSortedUseCase.kt +++ b/core/item/src/main/kotlin/de/davis/keygo/core/item/domain/usecase/ObserveAllTagsSortedUseCase.kt @@ -2,7 +2,7 @@ package de.davis.keygo.core.item.domain.usecase import de.davis.keygo.core.item.domain.model.Tag import de.davis.keygo.core.item.domain.repository.ItemRepository -import de.davis.keygo.core.util.domain.usecase.SortUseCase +import de.davis.keygo.core.util.domain.comparator.sortedNaturallyBy import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map import org.koin.core.annotation.Single @@ -10,8 +10,7 @@ import org.koin.core.annotation.Single @Single class ObserveAllTagsSortedUseCase( private val itemRepository: ItemRepository, - private val sortUseCase: SortUseCase, ) { operator fun invoke(): Flow> = - itemRepository.observeAllTags().map { tags -> sortUseCase(tags) { it.display } } + itemRepository.observeAllTags().map { tags -> tags.sortedNaturallyBy { it.display } } } diff --git a/core/item/src/test/kotlin/de/davis/keygo/core/item/domain/usecase/ObserveAllTagsSortedUseCaseTest.kt b/core/item/src/test/kotlin/de/davis/keygo/core/item/domain/usecase/ObserveAllTagsSortedUseCaseTest.kt index 996fd9b7f..a37065fe7 100644 --- a/core/item/src/test/kotlin/de/davis/keygo/core/item/domain/usecase/ObserveAllTagsSortedUseCaseTest.kt +++ b/core/item/src/test/kotlin/de/davis/keygo/core/item/domain/usecase/ObserveAllTagsSortedUseCaseTest.kt @@ -7,7 +7,6 @@ import de.davis.keygo.core.item.domain.model.KeyInformation import de.davis.keygo.core.item.domain.model.Login import de.davis.keygo.core.item.domain.model.Tag import de.davis.keygo.core.item.domain.model.Timestamp -import de.davis.keygo.core.util.domain.usecase.SortUseCase import kotlinx.coroutines.flow.first import kotlinx.coroutines.test.runTest import kotlin.test.Test @@ -17,10 +16,7 @@ class ObserveAllTagsSortedUseCaseTest { private val loginRepository = FakeLoginRepository() private val itemRepository = FakeItemRepository(loginRepository) - private val useCase = ObserveAllTagsSortedUseCase( - itemRepository = itemRepository, - sortUseCase = SortUseCase(), - ) + private val useCase = ObserveAllTagsSortedUseCase(itemRepository = itemRepository) private fun tag(value: String): Tag = Tag.of(value)!! diff --git a/core/security/src/main/kotlin/de/davis/keygo/core/security/data/crypto/CryptographicScopeProviderFactoryImpl.kt b/core/security/src/main/kotlin/de/davis/keygo/core/security/data/crypto/CryptographicScopeProviderFactoryImpl.kt index 20f5fb7a6..0bb965df0 100644 --- a/core/security/src/main/kotlin/de/davis/keygo/core/security/data/crypto/CryptographicScopeProviderFactoryImpl.kt +++ b/core/security/src/main/kotlin/de/davis/keygo/core/security/data/crypto/CryptographicScopeProviderFactoryImpl.kt @@ -5,6 +5,7 @@ import de.davis.keygo.core.security.domain.Session import de.davis.keygo.core.security.domain.crypto.CryptographicScopeProvider import de.davis.keygo.core.security.domain.crypto.CryptographicScopeProviderFactory import de.davis.keygo.rust.item.ItemManager +import de.davis.keygo.rust.vault.VaultManager import de.davis.keygo.rust.wrap.KeyWrapper import org.koin.core.annotation.Single @@ -13,8 +14,15 @@ internal class CryptographicScopeProviderFactoryImpl( private val itemRepository: ItemRepository, private val itemManager: ItemManager, private val keyWrapper: KeyWrapper, + private val vaultManager: VaultManager, ) : CryptographicScopeProviderFactory { override fun forSession(session: Session): CryptographicScopeProvider = - CryptographicScopeProviderImpl(session, itemRepository, itemManager, keyWrapper) + CryptographicScopeProviderImpl( + session, + itemRepository, + itemManager, + keyWrapper, + vaultManager + ) } diff --git a/core/security/src/main/kotlin/de/davis/keygo/core/security/data/crypto/CryptographicScopeProviderImpl.kt b/core/security/src/main/kotlin/de/davis/keygo/core/security/data/crypto/CryptographicScopeProviderImpl.kt index 1f716f436..8792d7e61 100644 --- a/core/security/src/main/kotlin/de/davis/keygo/core/security/data/crypto/CryptographicScopeProviderImpl.kt +++ b/core/security/src/main/kotlin/de/davis/keygo/core/security/data/crypto/CryptographicScopeProviderImpl.kt @@ -15,6 +15,7 @@ import de.davis.keygo.core.util.mapFailure import de.davis.keygo.core.util.mapSuccess import de.davis.keygo.core.util.resultBinding import de.davis.keygo.rust.item.ItemManager +import de.davis.keygo.rust.vault.VaultManager import de.davis.keygo.rust.wrap.KeyWrapper import de.davis.keygo.rust.wrap.unwrapItemKeyWithResult import de.davis.keygo.rust.wrap.wrapItemKeyWithResult @@ -22,6 +23,7 @@ import de.davisalessandro.keygo.rust.ItemAad import de.davisalessandro.keygo.rust.KeyWrapException import de.davisalessandro.keygo.rust.WrappedKeyBlob import org.koin.core.annotation.Single +import java.util.UUID @Single internal class CryptographicScopeProviderImpl( @@ -29,6 +31,7 @@ internal class CryptographicScopeProviderImpl( private val itemRepository: ItemRepository, private val itemManager: ItemManager, private val keyWrapper: KeyWrapper, + private val vaultManager: VaultManager, ) : CryptographicScopeProvider { override suspend fun itemScope( @@ -84,6 +87,32 @@ internal class CryptographicScopeProviderImpl( ).block() } + override suspend fun accountScope( + namespace: UUID, + wrapped: KeyInformation?, + block: suspend CryptographicScope.() -> R + ): Result = resultBinding { + val (key, wrappedKey) = if (wrapped != null) { + val unwrapped = session.unwrapVaultKey( + wrapped = wrapped.toWrappedKeyBlob(), + vaultId = namespace, + ).mapFailure { it.toCryptoScopeError() }.bind() + unwrapped to wrapped + } else { + val fresh = vaultManager.createNewVaultKey() + val blob = session.wrapVaultKey(vaultKey = fresh, vaultId = namespace) + .mapFailure { it.toCryptoScopeError() }.bind() + fresh to blob.toKeyInformation() + } + + CryptographicScopeImpl( + itemKey = key, + itemAad = ItemAad(itemId = namespace, vaultId = namespace), + itemManager = itemManager, + wrapAction = { wrappedKey }, + ).block() + } + override suspend fun rewrapItemKey( sourceVault: WrappedVaultKeyInformation, sourceItem: WrappedItemKeyInformation, diff --git a/core/security/src/main/kotlin/de/davis/keygo/core/security/domain/crypto/CryptographicScopeProvider.kt b/core/security/src/main/kotlin/de/davis/keygo/core/security/domain/crypto/CryptographicScopeProvider.kt index f946598b3..6fd3d4d99 100644 --- a/core/security/src/main/kotlin/de/davis/keygo/core/security/domain/crypto/CryptographicScopeProvider.kt +++ b/core/security/src/main/kotlin/de/davis/keygo/core/security/domain/crypto/CryptographicScopeProvider.kt @@ -6,6 +6,7 @@ import de.davis.keygo.core.security.domain.crypto.model.WrappedItemKeyInformatio import de.davis.keygo.core.security.domain.crypto.model.WrappedVaultKeyInformation import de.davis.keygo.core.security.domain.model.CryptoScopeError import de.davis.keygo.core.util.Result +import java.util.UUID interface CryptographicScopeProvider { @@ -20,6 +21,12 @@ interface CryptographicScopeProvider { block: suspend CryptographicScope.() -> R, ): Result + suspend fun accountScope( + namespace: UUID, + wrapped: KeyInformation?, + block: suspend CryptographicScope.() -> R, + ): Result + /** * Re-wraps an item's key from one vault to another without exposing the unwrapped item key * or touching the item's encrypted secrets. The destination AAD reuses the item id but is diff --git a/core/security/src/test/kotlin/de/davis/keygo/core/security/crypto/CryptographicScopeImplTest.kt b/core/security/src/test/kotlin/de/davis/keygo/core/security/crypto/CryptographicScopeImplTest.kt index 7e9def141..731704125 100644 --- a/core/security/src/test/kotlin/de/davis/keygo/core/security/crypto/CryptographicScopeImplTest.kt +++ b/core/security/src/test/kotlin/de/davis/keygo/core/security/crypto/CryptographicScopeImplTest.kt @@ -12,6 +12,7 @@ import de.davis.keygo.core.util.assertSuccess import de.davis.keygo.core.util.getOrNull import de.davis.keygo.rust.FakeItemManager import de.davis.keygo.rust.FakeKeyWrapper +import de.davis.keygo.rust.FakeVaultManager import de.davisalessandro.keygo.rust.ItemAad import kotlinx.coroutines.Deferred import kotlinx.coroutines.async @@ -35,8 +36,13 @@ class CryptographicScopeImplTest { private val itemManager = FakeItemManager() private val keyWrapper = FakeKeyWrapper() - private val provider = - CryptographicScopeProviderImpl(session, itemRepository, itemManager, keyWrapper) + private val provider = CryptographicScopeProviderImpl( + session, + itemRepository, + itemManager, + keyWrapper, + FakeVaultManager(), + ) private val label = "password" diff --git a/core/security/src/test/kotlin/de/davis/keygo/core/security/data/crypto/CryptographicScopeProviderImplTest.kt b/core/security/src/test/kotlin/de/davis/keygo/core/security/data/crypto/CryptographicScopeProviderImplTest.kt index bb3289760..578657cb5 100644 --- a/core/security/src/test/kotlin/de/davis/keygo/core/security/data/crypto/CryptographicScopeProviderImplTest.kt +++ b/core/security/src/test/kotlin/de/davis/keygo/core/security/data/crypto/CryptographicScopeProviderImplTest.kt @@ -13,6 +13,7 @@ import de.davis.keygo.core.util.Result import de.davis.keygo.core.util.isFailure import de.davis.keygo.rust.FakeItemManager import de.davis.keygo.rust.FakeKeyWrapper +import de.davis.keygo.rust.FakeVaultManager import de.davisalessandro.keygo.rust.ItemAad import de.davisalessandro.keygo.rust.KeyWrapException import kotlinx.coroutines.test.runTest @@ -29,6 +30,7 @@ class CryptographicScopeProviderImplTest { itemRepository = FakeItemRepository(), itemManager = FakeItemManager(), keyWrapper = FakeKeyWrapper(), + vaultManager = FakeVaultManager(), ) private fun wrappedVaultKeyInformation(vaultId: java.util.UUID) = WrappedVaultKeyInformation( diff --git a/core/security/src/testFixtures/kotlin/de/davis/keygo/core/security/crypto/BindingCryptographicScopeProvider.kt b/core/security/src/testFixtures/kotlin/de/davis/keygo/core/security/crypto/BindingCryptographicScopeProvider.kt index 3195cb1d9..d2429bbe5 100644 --- a/core/security/src/testFixtures/kotlin/de/davis/keygo/core/security/crypto/BindingCryptographicScopeProvider.kt +++ b/core/security/src/testFixtures/kotlin/de/davis/keygo/core/security/crypto/BindingCryptographicScopeProvider.kt @@ -6,6 +6,7 @@ import de.davis.keygo.core.security.domain.Session import de.davis.keygo.core.security.domain.crypto.CryptographicScopeProvider import de.davisalessandro.keygo.rust.ItemManagerInterface import de.davisalessandro.keygo.rust.KeyWrapperInterface +import de.davisalessandro.keygo.rust.VaultManagerInterface /** * Constructs the production [CryptographicScopeProvider] backed by the supplied fakes. @@ -21,9 +22,11 @@ fun BindingCryptographicScopeProvider( itemRepository: ItemRepository, itemManager: ItemManagerInterface, keyWrapper: KeyWrapperInterface, + vaultManager: VaultManagerInterface, ): CryptographicScopeProvider = CryptographicScopeProviderImpl( session = session, itemRepository = itemRepository, itemManager = itemManager, keyWrapper = keyWrapper, + vaultManager = vaultManager, ) diff --git a/core/security/src/testFixtures/kotlin/de/davis/keygo/core/security/crypto/FakeCryptographicScopeProvider.kt b/core/security/src/testFixtures/kotlin/de/davis/keygo/core/security/crypto/FakeCryptographicScopeProvider.kt index 4df7fa738..8bb556fc8 100644 --- a/core/security/src/testFixtures/kotlin/de/davis/keygo/core/security/crypto/FakeCryptographicScopeProvider.kt +++ b/core/security/src/testFixtures/kotlin/de/davis/keygo/core/security/crypto/FakeCryptographicScopeProvider.kt @@ -11,6 +11,7 @@ import de.davis.keygo.core.security.domain.crypto.model.WrappedVaultKeyInformati import de.davis.keygo.core.security.domain.model.CryptoScopeError import de.davis.keygo.core.util.Result import java.util.Collections +import java.util.UUID import kotlin.coroutines.CoroutineContext import kotlin.experimental.xor @@ -27,6 +28,8 @@ class FakeCryptographicScopeProvider( val sourceItem: WrappedItemKeyInformation, val destinationVault: WrappedVaultKeyInformation, ) : CallHistory + + class AccountScopeCall(val namespace: UUID, val hadKey: Boolean) : CallHistory } // Synchronized: BackupCollector calls encrypt/decrypt concurrently across items, and this @@ -36,6 +39,9 @@ class FakeCryptographicScopeProvider( get() = callHistory.filterIsInstance() val rewrapCalls get() = callHistory.filterIsInstance() + val accountScopeCalls + get() = callHistory.filterIsInstance() + /** Result returned by the next [rewrapItemKey] call. */ var rewrapResult: Result = @@ -72,28 +78,18 @@ class FakeCryptographicScopeProvider( ): Result { itemScopeFailure?.let { return Result.Failure(it) } - return block( - object : CryptographicScope { - override suspend fun ByteArray.encrypt( - label: String, - context: CoroutineContext, - ): CryptographicData { - callHistory += CallHistory.EncryptCall(label, this.copyOf()) - return CryptographicData(data = transform(this), iv = IV) - } - - override suspend fun CryptographicData.decrypt( - label: String, - context: CoroutineContext, - ): ByteArray { - callHistory += CallHistory.DecryptCall(label, this.data.copyOf()) - return transform(data) - } - - override suspend fun wrapCurrentItemKey(context: CoroutineContext): KeyInformation = - KeyInformation(byteArrayOf(), byteArrayOf()) - }, - ).let(Result::Success) + return block(recordingScope()).let(Result::Success) + } + + override suspend fun accountScope( + namespace: UUID, + wrapped: KeyInformation?, + block: suspend CryptographicScope.() -> R + ): Result { + callHistory += CallHistory.AccountScopeCall(namespace, wrapped != null) + itemScopeFailure?.let { return Result.Failure(it) } + + return block(recordingScope()).let(Result::Success) } override suspend fun rewrapItemKey( @@ -105,6 +101,27 @@ class FakeCryptographicScopeProvider( return rewrapResult } + private fun recordingScope() = object : CryptographicScope { + override suspend fun ByteArray.encrypt( + label: String, + context: CoroutineContext, + ): CryptographicData { + callHistory += CallHistory.EncryptCall(label, this.copyOf()) + return CryptographicData(data = transform(this), iv = IV) + } + + override suspend fun CryptographicData.decrypt( + label: String, + context: CoroutineContext, + ): ByteArray { + callHistory += CallHistory.DecryptCall(label, this.data.copyOf()) + return transform(data) + } + + override suspend fun wrapCurrentItemKey(context: CoroutineContext): KeyInformation = + KeyInformation(byteArrayOf(), byteArrayOf()) + } + companion object { val IV: ByteArray = byteArrayOf(0x01, 0x02, 0x03, 0x04) private val KEY: ByteArray = byteArrayOf(0xCA.toByte(), 0xFE.toByte()) diff --git a/core/ui/src/main/kotlin/de/davis/keygo/core/ui/components/KeyGoCard.kt b/core/ui/src/main/kotlin/de/davis/keygo/core/ui/components/KeyGoCard.kt index 661ab5421..2420c78e9 100644 --- a/core/ui/src/main/kotlin/de/davis/keygo/core/ui/components/KeyGoCard.kt +++ b/core/ui/src/main/kotlin/de/davis/keygo/core/ui/components/KeyGoCard.kt @@ -36,17 +36,29 @@ data class KeyGoCardProperties( companion object { @Composable - fun outlined(containerColor: Color = Color.Unspecified) = KeyGoCardProperties( + fun outlined( + containerColor: Color = Color.Unspecified, + contentColor: Color = Color.Unspecified + ) = KeyGoCardProperties( shape = CardDefaults.outlinedShape, - colors = CardDefaults.outlinedCardColors(containerColor = containerColor), + colors = CardDefaults.outlinedCardColors( + containerColor = containerColor, + contentColor = contentColor + ), elevation = CardDefaults.outlinedCardElevation(), border = CardDefaults.outlinedCardBorder() ) @Composable - fun elevated(containerColor: Color = Color.Unspecified) = KeyGoCardProperties( + fun elevated( + containerColor: Color = Color.Unspecified, + contentColor: Color = Color.Unspecified + ) = KeyGoCardProperties( shape = CardDefaults.elevatedShape, - colors = CardDefaults.elevatedCardColors(containerColor = containerColor), + colors = CardDefaults.elevatedCardColors( + containerColor = containerColor, + contentColor = contentColor + ), elevation = CardDefaults.elevatedCardElevation(), border = null ) diff --git a/core/ui/src/main/kotlin/de/davis/keygo/core/ui/components/KeyGoSwitch.kt b/core/ui/src/main/kotlin/de/davis/keygo/core/ui/components/KeyGoSwitch.kt index 8fe56909e..feccdca6c 100644 --- a/core/ui/src/main/kotlin/de/davis/keygo/core/ui/components/KeyGoSwitch.kt +++ b/core/ui/src/main/kotlin/de/davis/keygo/core/ui/components/KeyGoSwitch.kt @@ -13,6 +13,7 @@ import androidx.compose.material3.ListItemShapes import androidx.compose.material3.Switch import androidx.compose.material3.SwitchDefaults import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @OptIn(ExperimentalMaterial3ExpressiveApi::class) @@ -21,6 +22,7 @@ fun KeyGoSwitch( checked: Boolean, onCheckedChange: (Boolean) -> Unit, modifier: Modifier = Modifier, + verticalAlignment: Alignment.Vertical = ListItemDefaults.verticalAlignment(), overlineContent: @Composable (() -> Unit)? = null, supportingContent: @Composable (() -> Unit)? = null, leadingContent: @Composable (() -> Unit)? = null, @@ -35,6 +37,7 @@ fun KeyGoSwitch( overlineContent = overlineContent, supportingContent = supportingContent, leadingContent = leadingContent, + verticalAlignment = verticalAlignment, trailingContent = { Switch( checked = checked, diff --git a/core/ui/src/main/res/drawable/ic_app.xml b/core/ui/src/main/res/drawable/ic_app.xml new file mode 100644 index 000000000..edb6c953a --- /dev/null +++ b/core/ui/src/main/res/drawable/ic_app.xml @@ -0,0 +1,30 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/core/util/src/main/kotlin/de/davis/keygo/core/util/domain/comparator/NaturalOrderComparator.kt b/core/util/src/main/kotlin/de/davis/keygo/core/util/domain/comparator/NaturalOrderComparator.kt new file mode 100644 index 000000000..d0de5478d --- /dev/null +++ b/core/util/src/main/kotlin/de/davis/keygo/core/util/domain/comparator/NaturalOrderComparator.kt @@ -0,0 +1,89 @@ +package de.davis.keygo.core.util.domain.comparator + +import java.text.Collator + +object NaturalOrderComparator : Comparator { + + // Locale-aware for text runs: ä, ö, ü, &, / etc. + private val collator = ThreadLocal.withInitial { + Collator.getInstance().apply { strength = Collator.PRIMARY } + } + + // Walks both strings run by run (a run is all digits or no digits) instead of splitting them, + // since this runs O(n log n) times per sort + override fun compare(o1: String?, o2: String?): Int { + if (o1 == null || o2 == null) return compareValues(o1, o2) + + var i = 0 + var j = 0 + while (i < o1.length && j < o2.length) { + val digits1 = o1[i].isAsciiDigit() + val digits2 = o2[j].isAsciiDigit() + val end1 = runEnd(o1, i, digits1) + val end2 = runEnd(o2, j, digits2) + + val cmp = when { + digits1 && digits2 -> compareNumeric(o1, i, end1, o2, j, end2) + // Shared runs ("Server " in "Server 2" vs "Server 10") are common and the collator + // plus its substrings is the slow part, so identical runs skip it. Only exact + // equality may: any looser shortcut (ASCII order, ignoreCase) disagrees with the + // collator somewhere and breaks transitivity + end1 - i == end2 - j && o1.regionMatches(i, o2, j, end1 - i) -> 0 + else -> collator.get()!!.compare(o1.substring(i, end1), o2.substring(j, end2)) + } + + if (cmp != 0) return cmp + i = end1 + j = end2 + } + + // Whoever has runs left is longer: "item" < "item1" + return (o1.length - i).compareTo(o2.length - j) + } + + private fun Char.isAsciiDigit() = this in '0'..'9' + + private fun runEnd(s: String, from: Int, digits: Boolean): Int { + var k = from + while (k < s.length && s[k].isAsciiDigit() == digits) k++ + return k + } + + private fun compareNumeric( + a: String, + aStart: Int, + aEnd: Int, + b: String, + bStart: Int, + bEnd: Int + ): Int { + var i = aStart + while (i < aEnd && a[i] == '0') i++ + var j = bStart + while (j < bEnd && b[j] == '0') j++ + + // Without leading zeros, different lengths mean different magnitudes, so no parsing + val lengthDiff = (aEnd - i) - (bEnd - j) + if (lengthDiff != 0) return lengthDiff + + // Same length: digit-by-digit order == numeric order + while (i < aEnd) { + val d = a[i++] - b[j++] + if (d != 0) return d + } + return 0 + } +} + +fun Iterable.sortedNaturally(): List = sortedWith(NaturalOrderComparator) + +fun Iterable.sortedNaturallyDescending(): List = + sortedWith(NaturalOrderComparator.reversed()) + +inline fun Iterable.sortedNaturallyBy(crossinline selector: (T) -> String): List = + sortedWith(compareBy(NaturalOrderComparator, selector)) + +inline fun Iterable.sortedNaturallyByDescending(crossinline selector: (T) -> String): List = + sortedWith(compareByDescending(NaturalOrderComparator, selector)) + +fun MutableList.sortNaturally() = sortWith(NaturalOrderComparator) diff --git a/core/util/src/main/kotlin/de/davis/keygo/core/util/domain/usecase/SortUseCase.kt b/core/util/src/main/kotlin/de/davis/keygo/core/util/domain/usecase/SortUseCase.kt deleted file mode 100644 index 0da5a5a34..000000000 --- a/core/util/src/main/kotlin/de/davis/keygo/core/util/domain/usecase/SortUseCase.kt +++ /dev/null @@ -1,64 +0,0 @@ -package de.davis.keygo.core.util.domain.usecase - -import org.koin.core.annotation.Single -import java.text.Collator - -@Single -class SortUseCase { - - private val collator = ThreadLocal.withInitial { - Collator.getInstance().apply { - strength = Collator.PRIMARY // case- and accent-insensitive grouping - } - } - - operator fun invoke( - items: Iterable, - ascending: Boolean = true, - selector: (T) -> String, - ): List { - val prepared = items.map { item -> - item to selector(item).split(CHUNK_REGEX) - } - - val comparator = Comparator>> { a, b -> - val cmp = compareParts(a.second, b.second) - if (ascending) cmp else -cmp - } - - return prepared.sortedWith(comparator).map { it.first } - } - - private fun compareParts(parts1: List, parts2: List): Int { - val len = minOf(parts1.size, parts2.size) - - for (i in 0 until len) { - val p1 = parts1[i] - val p2 = parts2[i] - - // Guard against empty chunks produced by splitting blank/empty names - if (p1.isEmpty() || p2.isEmpty()) return p1.length - p2.length - - val cmp = when { - // split regex guarantees entire chunk is digits if first char is - p1[0].isDigit() && p2[0].isDigit() -> compareNumeric(p1, p2) - else -> collator.get()!!.compare(p1, p2) // locale-aware: ä, ö, ü, &, / etc. - } - - if (cmp != 0) return cmp - } - - return parts1.size.compareTo(parts2.size) - } - - private fun compareNumeric(a: String, b: String): Int { - // Fast path: different lengths mean different magnitudes, so no parsing - if (a.length != b.length) return a.length - b.length - // Same length: lexicographic order == numeric order for digit-only strings - return a.compareTo(b) - } - - companion object { - private val CHUNK_REGEX = Regex("(?<=\\D)(?=\\d)|(?<=\\d)(?=\\D)") - } -} diff --git a/core/util/src/test/kotlin/de/davis/keygo/core/util/domain/comparator/NaturalOrderComparatorTest.kt b/core/util/src/test/kotlin/de/davis/keygo/core/util/domain/comparator/NaturalOrderComparatorTest.kt new file mode 100644 index 000000000..970636e1d --- /dev/null +++ b/core/util/src/test/kotlin/de/davis/keygo/core/util/domain/comparator/NaturalOrderComparatorTest.kt @@ -0,0 +1,232 @@ +package de.davis.keygo.core.util.domain.comparator + +import kotlin.math.sign +import kotlin.test.Test +import kotlin.test.assertEquals + +class NaturalOrderComparatorTest { + + @Test + fun `sorts ascending case-insensitively`() { + val result = listOf("banana", "Apple", "cherry").sortedNaturally() + assertEquals(listOf("Apple", "banana", "cherry"), result) + } + + @Test + fun `empty input returns empty list`() { + assertEquals(emptyList(), listOf().sortedNaturally()) + } + + @Test + fun `single element list is returned unchanged`() { + assertEquals(listOf("only"), listOf("only").sortedNaturally()) + } + + @Test + fun `empty strings sort first`() { + val result = listOf("b", "", "a").sortedNaturally() + assertEquals(listOf("", "a", "b"), result) + } + + @Test + fun `shorter prefix sorts before longer string`() { + val result = listOf("item1", "item", "itemA").sortedNaturally() + assertEquals(listOf("item", "item1", "itemA"), result) + } + + @Test + fun `natural numeric ordering puts item2 before item10`() { + val result = listOf("item10", "item2", "item1").sortedNaturally() + assertEquals(listOf("item1", "item2", "item10"), result) + } + + @Test + fun `leading numbers are compared numerically`() { + val result = listOf("10 apples", "2 apples", "1 apple").sortedNaturally() + assertEquals(listOf("1 apple", "2 apples", "10 apples"), result) + } + + @Test + fun `pure numeric strings are compared numerically`() { + val result = listOf("100", "20", "3", "1000").sortedNaturally() + assertEquals(listOf("3", "20", "100", "1000"), result) + } + + @Test + fun `multiple numeric segments are each compared numerically`() { + val result = listOf("v1.10.0", "v1.2.10", "v1.2.0", "v2.0.0").sortedNaturally() + assertEquals(listOf("v1.2.0", "v1.2.10", "v1.10.0", "v2.0.0"), result) + } + + @Test + fun `numbers larger than Long do not overflow`() { + val huge = "file" + "9".repeat(30) + val bigger = "file1" + "0".repeat(30) + val result = listOf(bigger, huge, "file1").sortedNaturally() + assertEquals(listOf("file1", huge, bigger), result) + } + + @Test + fun `leading zeros do not inflate magnitude`() { + val result = listOf("item10", "item007", "item2").sortedNaturally() + assertEquals(listOf("item2", "item007", "item10"), result) + } + + @Test + fun `numbers differing only in leading zeros compare as equal`() { + assertEquals(0, NaturalOrderComparator.compare("a7", "a007")) + } + + @Test + fun `leading-zero-only duplicates keep input order`() { + assertEquals(listOf("a007", "a7", "a07"), listOf("a007", "a7", "a07").sortedNaturally()) + assertEquals(listOf("a07", "a007", "a7"), listOf("a07", "a007", "a7").sortedNaturally()) + } + + @Test + fun `all-zero numbers sort before non-zero ones`() { + val result = listOf("x5", "x000", "x1").sortedNaturally() + assertEquals(listOf("x000", "x1", "x5"), result) + } + + @Test + fun `case differences do not break numeric ordering`() { + val result = listOf("file10", "File2", "FILE1").sortedNaturally() + assertEquals(listOf("FILE1", "File2", "file10"), result) + } + + @Test + fun `strings differing only in case compare as equal`() { + assertEquals(0, NaturalOrderComparator.compare("Keygo", "KEYGO")) + } + + @Test + fun `case-only duplicates keep input order`() { + assertEquals(listOf("a", "A", "b"), listOf("b", "a", "A").sortedNaturally()) + assertEquals(listOf("A", "a", "b"), listOf("b", "A", "a").sortedNaturally()) + } + + @Test + fun `umlauts sort alongside their base letter`() { + // Assumes a default locale such as de or en (not e.g. sv, where Ä follows Z) + val result = listOf("Zebra", "Äpfel", "Birne").sortedNaturally() + assertEquals(listOf("Äpfel", "Birne", "Zebra"), result) + } + + @Test + fun `accented and unaccented letters compare as equal`() { + assertEquals(0, NaturalOrderComparator.compare("Muller", "Müller")) + } + + @Test + fun `null compares equal to null`() { + assertEquals(0, NaturalOrderComparator.compare(null, null)) + } + + @Test + fun `null sorts before non-null values`() { + assert(NaturalOrderComparator.compare(null, "a") < 0) + assert(NaturalOrderComparator.compare("a", null) > 0) + } + + private val samples = listOf( + "", "a", "A", "b", "item", "item1", "item2", "item10", "Item10", + "10", "2", "v1.2.0", "v1.10.0", "Äpfel", "apfel", "x9y", "x10y", + "x09y", "x009y", "item02", "0", "000", + "x٣", "٣", "x5", "٣x", + ) + + @Test + fun `comparison is antisymmetric`() { + for (a in samples) for (b in samples) { + val ab = NaturalOrderComparator.compare(a, b).sign + val ba = NaturalOrderComparator.compare(b, a).sign + assertEquals(-ab, ba, "compare($a, $b) and compare($b, $a) disagree") + } + } + + @Test + fun `comparison is transitive`() { + for (a in samples) for (b in samples) for (c in samples) { + val ab = NaturalOrderComparator.compare(a, b) + val bc = NaturalOrderComparator.compare(b, c) + if (ab <= 0 && bc <= 0) { + assert(NaturalOrderComparator.compare(a, c) <= 0) { + "Transitivity violated for $a <= $b <= $c" + } + } + } + } + + @Test + fun `every string compares equal to itself`() { + samples.forEach { assertEquals(0, NaturalOrderComparator.compare(it, it), "for '$it'") } + } + + @Test + fun `sorting is idempotent`() { + val once = samples.shuffled().sortedNaturally() + assertEquals(once, once.sortedNaturally()) + } + + @Test + fun `descending reverses the natural order`() { + val result = listOf("item2", "item10", "item1").sortedNaturallyDescending() + assertEquals(listOf("item10", "item2", "item1"), result) + } + + @Test + fun `descending is the exact reverse of ascending for distinct keys`() { + val input = listOf("b10", "a2", "c1", "a10", "b2") + assertEquals(input.sortedNaturally().reversed(), input.sortedNaturallyDescending()) + } + + private data class Box(val name: String, val tag: Int) + + @Test + fun `sorts by a non-identity selector`() { + val result = listOf(Box("z", 1), Box("a", 2)).sortedNaturallyBy { it.name } + assertEquals(listOf(Box("a", 2), Box("z", 1)), result) + } + + @Test + fun `selector uses natural numeric ordering`() { + val input = listOf(Box("Key 10", 1), Box("Key 2", 2), Box("Key 1", 3)) + val result = input.sortedNaturallyBy { it.name } + assertEquals(listOf(3, 2, 1), result.map { it.tag }) + } + + @Test + fun `descending selector reverses order`() { + val input = listOf(Box("Key 2", 1), Box("Key 10", 2), Box("Key 1", 3)) + val result = input.sortedNaturallyByDescending { it.name } + assertEquals(listOf(2, 1, 3), result.map { it.tag }) + } + + @Test + fun `equal sort keys preserve input order (stable)`() { + val result = listOf(Box("x", 1), Box("x", 2), Box("x", 3)).sortedNaturallyBy { it.name } + assertEquals(listOf(1, 2, 3), result.map { it.tag }) + } + + @Test + fun `keys equal under collator preserve input order (stable)`() { + val input = listOf(Box("KEY", 1), Box("key", 2), Box("Key", 3)) + val result = input.sortedNaturallyBy { it.name } + assertEquals(listOf(1, 2, 3), result.map { it.tag }) + } + + @Test + fun `sortedNaturally does not mutate the original list`() { + val input = mutableListOf("b", "a") + input.sortedNaturally() + assertEquals(listOf("b", "a"), input) + } + + @Test + fun `sortNaturally sorts a mutable list in place`() { + val list = mutableListOf("item10", "item2", "item1") + list.sortNaturally() + assertEquals(listOf("item1", "item2", "item10"), list) + } +} diff --git a/core/util/src/test/kotlin/de/davis/keygo/core/util/domain/usecase/SortUseCaseTest.kt b/core/util/src/test/kotlin/de/davis/keygo/core/util/domain/usecase/SortUseCaseTest.kt deleted file mode 100644 index 54ff26efe..000000000 --- a/core/util/src/test/kotlin/de/davis/keygo/core/util/domain/usecase/SortUseCaseTest.kt +++ /dev/null @@ -1,59 +0,0 @@ -package de.davis.keygo.core.util.domain.usecase - -import kotlin.test.Test -import kotlin.test.assertEquals - -class SortUseCaseTest { - - private val sort = SortUseCase() - - private operator fun SortUseCase.invoke( - items: Iterable, - ascending: Boolean = true, - ) = invoke(items, ascending) { it } - - @Test - fun `sorts ascending case-insensitively by selector`() { - val result = sort(listOf("banana", "Apple", "cherry")) - assertEquals(listOf("Apple", "banana", "cherry"), result) - } - - @Test - fun `natural numeric ordering puts item2 before item10`() { - val result = sort(listOf("item10", "item2", "item1")) - assertEquals(listOf("item1", "item2", "item10"), result) - } - - @Test - fun `descending reverses the natural order`() { - val result = sort(listOf("item2", "item10", "item1"), ascending = false) - assertEquals(listOf("item10", "item2", "item1"), result) - } - - @Test - fun `blank and empty selector values sort first`() { - val result = sort(listOf("b", "", "a")) - assertEquals(listOf("", "a", "b"), result) - } - - @Test - fun `sorts by a non-identity selector`() { - data class Box(val name: String, val n: Int) - - val result = sort(listOf(Box("z", 1), Box("a", 2))) { it.name } - assertEquals(listOf(Box("a", 2), Box("z", 1)), result) - } - - @Test - fun `equal sort keys preserve input order (stable)`() { - data class Box(val name: String, val tag: Int) - - val result = sort(listOf(Box("x", 1), Box("x", 2), Box("x", 3))) { it.name } - assertEquals(listOf(1, 2, 3), result.map { it.tag }) - } - - @Test - fun `empty input returns empty list`() { - assertEquals(emptyList(), sort(emptyList())) - } -} diff --git a/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/di/FeatureBackupModule.kt b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/di/FeatureBackupModule.kt index 95ad98c46..ecad83d8f 100644 --- a/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/di/FeatureBackupModule.kt +++ b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/di/FeatureBackupModule.kt @@ -2,7 +2,6 @@ package de.davis.keygo.feature.backup.di import android.content.Context import androidx.datastore.dataStore -import androidx.work.WorkManager import de.davis.keygo.core.util.data.serializer.DefaultProtoSerializer import de.davis.keygo.feature.backup.data.local.model.ProtoBackupArkData import de.davis.keygo.feature.backup.data.local.model.ProtoBackupJobs @@ -43,8 +42,4 @@ object FeatureBackupModule { @BackupArkQualifier internal fun provideBackupArkDataStore(context: Context) = context.backupArkDataStore - - @Single - internal fun provideWorkManager(context: Context): WorkManager = - WorkManager.getInstance(context) } \ No newline at end of file diff --git a/feature/backup/src/test/kotlin/de/davis/keygo/feature/backup/presentation/import/ImportWizardViewModelTest.kt b/feature/backup/src/test/kotlin/de/davis/keygo/feature/backup/presentation/import/ImportWizardViewModelTest.kt index f05369a5c..5ed70eb57 100644 --- a/feature/backup/src/test/kotlin/de/davis/keygo/feature/backup/presentation/import/ImportWizardViewModelTest.kt +++ b/feature/backup/src/test/kotlin/de/davis/keygo/feature/backup/presentation/import/ImportWizardViewModelTest.kt @@ -6,7 +6,6 @@ import de.davis.keygo.core.item.domain.model.Vault import de.davis.keygo.core.item.domain.model.VaultContext import de.davis.keygo.core.security.FakeSession import de.davis.keygo.core.security.domain.Session -import de.davis.keygo.core.util.domain.usecase.SortUseCase import de.davis.keygo.feature.backup.FakeBackupFileStore import de.davis.keygo.feature.backup.RestorerTestEnv import de.davis.keygo.feature.backup.backupVault @@ -104,7 +103,7 @@ class ImportWizardViewModelTest { resolver, ImportBackupUseCase(fileStore, json, csv, env.restorer, session), AnalyzeCsvUseCase(fileStore, csv), - ObserveVaultsAndSelectionUseCase(env.vaultRepo, contextRepo, SortUseCase()), + ObserveVaultsAndSelectionUseCase(env.vaultRepo, contextRepo), ).also { it.state.launchIn(backgroundScope) } private fun ImportWizardViewModel.selectJson() { @@ -746,29 +745,33 @@ class ImportWizardViewModelTest { viewModel.seedFile(BackupDestinationUri("content://doc/keygo.json")) val finalState = viewModel.state.first { it.progress is ImportProgress.Succeeded } - assertEquals(1, assertIs(finalState.progress).summary.imported) + assertEquals( + 1, + assertIs(finalState.progress).summary.imported + ) assertEquals(emptyList(), finalState.columns) } @Test - fun `seedFile resolves the destination without a concurrent state write re-running it`() = runTest { - val gate = CompletableDeferred() - val resolver = FakeBackupDestinationResolver(result = textDestination(), gate = gate) - val viewModel = viewModel(resolver) + fun `seedFile resolves the destination without a concurrent state write re-running it`() = + runTest { + val gate = CompletableDeferred() + val resolver = FakeBackupDestinationResolver(result = textDestination(), gate = gate) + val viewModel = viewModel(resolver) - viewModel.seedFile(BackupDestinationUri("content://doc/notes.txt")) - // seedFile's coroutine has called resolve() and is now parked on the gate, mid-CAS-lambda. - runCurrent() + viewModel.seedFile(BackupDestinationUri("content://doc/notes.txt")) + // seedFile's coroutine has called resolve() and is now parked on the gate, mid-CAS-lambda. + runCurrent() - // A state write that lands while that lambda is still suspended: this is the same window - // the passphraseState.clearText() collector writes into in the real flow. If resolve() were - // still called from inside _state.update, the CAS retry this forces would call it again. - env.vaultRepo.seed(testVault(name = "Personal")) - runCurrent() + // A state write that lands while that lambda is still suspended: this is the same window + // the passphraseState.clearText() collector writes into in the real flow. If resolve() were + // still called from inside _state.update, the CAS retry this forces would call it again. + env.vaultRepo.seed(testVault(name = "Personal")) + runCurrent() - gate.complete(Unit) - viewModel.state.first { it.backupDestination != null } + gate.complete(Unit) + viewModel.state.first { it.backupDestination != null } - assertEquals(listOf(BackupDestinationUri("content://doc/notes.txt")), resolver.calls) - } + assertEquals(listOf(BackupDestinationUri("content://doc/notes.txt")), resolver.calls) + } } diff --git a/feature/item/create/src/test/java/de/davis/keygo/feature/item/create/presentation/login/LoginViewModelTest.kt b/feature/item/create/src/test/java/de/davis/keygo/feature/item/create/presentation/login/LoginViewModelTest.kt index d57ff5dda..dc9286fd0 100644 --- a/feature/item/create/src/test/java/de/davis/keygo/feature/item/create/presentation/login/LoginViewModelTest.kt +++ b/feature/item/create/src/test/java/de/davis/keygo/feature/item/create/presentation/login/LoginViewModelTest.kt @@ -24,7 +24,6 @@ import de.davis.keygo.core.security.domain.usecase.ItemWithCryptoScopeUseCase import de.davis.keygo.core.util.FakeRegistrableDomainResolver import de.davis.keygo.core.util.domain.model.snackbar.SnackbarMessage import de.davis.keygo.core.util.domain.snackbar.SnackbarManager -import de.davis.keygo.core.util.domain.usecase.SortUseCase import de.davis.keygo.feature.item.core.domain.model.NewPasskey import de.davis.keygo.feature.item.core.domain.usecase.CreateNewOrUpdateLoginUseCase import de.davis.keygo.feature.item.core.domain.usecase.ValidateTotpInputUseCase @@ -354,7 +353,7 @@ class LoginViewModelTest { totpService = totpService, vaultContextRepository = vaultContextRepository, itemRepository = itemRepository, - observeAllTags = ObserveAllTagsSortedUseCase(itemRepository, SortUseCase()), + observeAllTags = ObserveAllTagsSortedUseCase(itemRepository), vaultRepository = vaultRepository, validateTotpInput = ValidateTotpInputUseCase(totpService) ) diff --git a/feature/item/view/src/main/kotlin/de/davis/keygo/feature/item/view/creditcard/ViewCreditCardViewModel.kt b/feature/item/view/src/main/kotlin/de/davis/keygo/feature/item/view/creditcard/ViewCreditCardViewModel.kt index 99d9345c6..8770a60b7 100644 --- a/feature/item/view/src/main/kotlin/de/davis/keygo/feature/item/view/creditcard/ViewCreditCardViewModel.kt +++ b/feature/item/view/src/main/kotlin/de/davis/keygo/feature/item/view/creditcard/ViewCreditCardViewModel.kt @@ -12,7 +12,7 @@ import de.davis.keygo.core.item.domain.usecase.ObserveAllTagsSortedUseCase import de.davis.keygo.core.item.generated.domain.model.VaultItemType import de.davis.keygo.core.security.domain.crypto.decrypt import de.davis.keygo.core.security.domain.usecase.ItemWithCryptoScopeUseCase -import de.davis.keygo.core.util.domain.usecase.SortUseCase +import de.davis.keygo.core.util.domain.comparator.sortedNaturallyBy import de.davis.keygo.core.util.getOrNull import de.davis.keygo.core.util.onFailure import de.davis.keygo.core.util.onSuccess @@ -63,7 +63,6 @@ internal class ViewCreditCardViewModel( private val updateCreditCard: CreateNewOrUpdateCreditCardUseCase, private val observeCreditCardWithCryptoScope: ItemWithCryptoScopeUseCase, private val observeAllTags: ObserveAllTagsSortedUseCase, - private val sort: SortUseCase, private val cardFormatter: CardFormatter, ) : ViewModel() { @@ -106,7 +105,7 @@ internal class ViewCreditCardViewModel( expiryStatus = card.expirationDate?.let { CardExpiryStatus.of(it, YearMonth.now()) }, - tags = sort(card.tags) { it.display }.toSet(), + tags = card.tags.sortedNaturallyBy { it.display }.toSet(), note = card.note.orEmpty(), pinned = card.pinned, ) diff --git a/feature/item/view/src/main/kotlin/de/davis/keygo/feature/item/view/login/ViewLoginViewModel.kt b/feature/item/view/src/main/kotlin/de/davis/keygo/feature/item/view/login/ViewLoginViewModel.kt index bc6811ec7..0ddfddaba 100644 --- a/feature/item/view/src/main/kotlin/de/davis/keygo/feature/item/view/login/ViewLoginViewModel.kt +++ b/feature/item/view/src/main/kotlin/de/davis/keygo/feature/item/view/login/ViewLoginViewModel.kt @@ -11,7 +11,7 @@ import de.davis.keygo.core.item.domain.usecase.ObserveAllTagsSortedUseCase import de.davis.keygo.core.item.generated.domain.model.VaultItemType import de.davis.keygo.core.security.domain.crypto.decrypt import de.davis.keygo.core.security.domain.usecase.ItemWithCryptoScopeUseCase -import de.davis.keygo.core.util.domain.usecase.SortUseCase +import de.davis.keygo.core.util.domain.comparator.sortedNaturallyBy import de.davis.keygo.core.util.fold import de.davis.keygo.core.util.getOrNull import de.davis.keygo.core.util.isSuccess @@ -66,7 +66,6 @@ internal class ViewLoginViewModel( private val updateLogin: CreateNewOrUpdateLoginUseCase, private val validateTotpInput: ValidateTotpInputUseCase, private val isValidUrl: IsValidUrlUseCase, - private val sort: SortUseCase, private val websiteHandler: WebsiteHandler, private val totpGenerator: TotpGenerator, private val totpService: TotpService, @@ -111,7 +110,7 @@ internal class ViewLoginViewModel( passwordStrengthScore = login.passwordCredential?.score, username = login.username.orEmpty(), domains = login.domainInfos.mapTo(mutableSetOf()) { it.value }, - tags = sort(login.tags) { it.display }.toSet(), + tags = login.tags.sortedNaturallyBy { it.display }.toSet(), note = login.note.orEmpty(), totpState = TotpState.NoTotp, pinned = login.pinned, @@ -327,7 +326,8 @@ internal class ViewLoginViewModel( dialog.copy( error = when { failure.contains(ItemUpsertError.BlankName) || - failure.contains(ItemUpsertError.Empty) -> InputFieldError.Empty + failure.contains(ItemUpsertError.Empty) -> InputFieldError.Empty + else -> InputFieldError.System }, ) diff --git a/feature/item/view/src/test/kotlin/de/davis/keygo/feature/item/view/creditcard/ViewCreditCardViewModelTest.kt b/feature/item/view/src/test/kotlin/de/davis/keygo/feature/item/view/creditcard/ViewCreditCardViewModelTest.kt index 3d5f4f0a1..2c084ed33 100644 --- a/feature/item/view/src/test/kotlin/de/davis/keygo/feature/item/view/creditcard/ViewCreditCardViewModelTest.kt +++ b/feature/item/view/src/test/kotlin/de/davis/keygo/feature/item/view/creditcard/ViewCreditCardViewModelTest.kt @@ -17,15 +17,15 @@ import de.davis.keygo.core.item.domain.usecase.ObserveAllTagsSortedUseCase import de.davis.keygo.core.item.domain.usecase.UpsertVaultItemUseCase import de.davis.keygo.core.security.crypto.FakeCryptographicScopeProvider import de.davis.keygo.core.security.domain.usecase.ItemWithCryptoScopeUseCase -import de.davis.keygo.core.util.domain.usecase.SortUseCase import de.davis.keygo.feature.item.core.domain.usecase.CreateNewOrUpdateCreditCardUseCase import de.davis.keygo.feature.item.view.creditcard.model.ViewCreditCardState import de.davis.keygo.feature.item.view.login.model.ObfuscatedString import de.davis.keygo.rust.FakeCardFormatter import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.cancel +import kotlinx.coroutines.cancelAndJoin import kotlinx.coroutines.flow.first +import kotlinx.coroutines.job import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.resetMain import kotlinx.coroutines.test.runTest @@ -142,7 +142,7 @@ class ViewCreditCardViewModelTest { try { return vm.state.first { it.name.isNotEmpty() } } finally { - vm.viewModelScope.cancel() + vm.viewModelScope.coroutineContext.job.cancelAndJoin() } } @@ -152,13 +152,12 @@ class ViewCreditCardViewModelTest { val state = vm.state.first { it.cardNumber != null } return state.cardNumber!! } finally { - vm.viewModelScope.cancel() + vm.viewModelScope.coroutineContext.job.cancelAndJoin() } } private fun makeViewModel(): ViewCreditCardViewModel { - val sort = SortUseCase() - val observeAllTags = ObserveAllTagsSortedUseCase(itemRepository, sort) + val observeAllTags = ObserveAllTagsSortedUseCase(itemRepository) val cryptoScopeUseCase = ItemWithCryptoScopeUseCase(vaultRepository, cryptoProvider) val upsertVaultItem = UpsertVaultItemUseCase(FakeLoginRepository(), creditCardRepository) val updateCreditCard = CreateNewOrUpdateCreditCardUseCase( @@ -175,7 +174,6 @@ class ViewCreditCardViewModelTest { updateCreditCard = updateCreditCard, observeCreditCardWithCryptoScope = cryptoScopeUseCase, observeAllTags = observeAllTags, - sort = sort, cardFormatter = cardFormatter, ) } diff --git a/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/domain/usecase/FilterUseCase.kt b/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/domain/usecase/FilterUseCase.kt index df7254f11..c6395dbb7 100644 --- a/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/domain/usecase/FilterUseCase.kt +++ b/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/domain/usecase/FilterUseCase.kt @@ -1,7 +1,7 @@ package de.davis.keygo.feature.list_screen.domain.usecase import de.davis.keygo.core.item.domain.model.lite.LiteItem -import de.davis.keygo.core.util.domain.usecase.SortUseCase +import de.davis.keygo.core.util.domain.comparator.NaturalOrderComparator import de.davis.keygo.feature.list_screen.domain.model.FacetSelections import de.davis.keygo.feature.list_screen.domain.model.FilterFacet import de.davis.keygo.feature.list_screen.domain.model.FilterState @@ -10,9 +10,7 @@ import de.davis.keygo.feature.list_screen.domain.model.SortDirection import org.koin.core.annotation.Single @Single -class FilterUseCase( - private val sortUseCase: SortUseCase, -) { +class FilterUseCase { operator fun invoke( filterState: FilterState, @@ -26,17 +24,21 @@ class FilterUseCase( .all { facet -> facet.matches(item, selections, attributes) } } - val (pinned, unpinned) = filtered.partition { it.pinned } - return sort(filterState.sortDirection, pinned) + - sort(filterState.sortDirection, unpinned) + return filtered.sortedWith( + compareByDescending { it.pinned } + .thenBy(filterState.sortDirection.nameOrder) { it.name }, + ) } + private val SortDirection.nameOrder: Comparator + get() = when (this) { + SortDirection.Ascending -> NaturalOrderComparator + SortDirection.Descending -> NaturalOrderComparator.reversed() + } + private fun FilterFacet.matches( item: LiteItem, selections: FacetSelections, attributes: ItemAttributes, ): Boolean = valuesFor(item, attributes).any { it in selections[this] } - - private fun sort(direction: SortDirection, items: List): List = - sortUseCase(items, ascending = direction == SortDirection.Ascending) { it.name } } diff --git a/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/domain/usecase/RankSearchResultsUseCase.kt b/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/domain/usecase/RankSearchResultsUseCase.kt index 60ff7cba1..efddc6a77 100644 --- a/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/domain/usecase/RankSearchResultsUseCase.kt +++ b/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/domain/usecase/RankSearchResultsUseCase.kt @@ -1,24 +1,18 @@ package de.davis.keygo.feature.list_screen.domain.usecase import de.davis.keygo.core.item.domain.model.lite.LiteItemSearchResult -import de.davis.keygo.core.util.domain.usecase.SortUseCase +import de.davis.keygo.core.util.domain.comparator.NaturalOrderComparator import org.koin.core.annotation.Single @Single -class RankSearchResultsUseCase( - private val sortUseCase: SortUseCase, -) { +class RankSearchResultsUseCase { operator fun invoke( query: String, results: List, - ): List { - val alphabetical = sortUseCase(results) { it.name } - if (query.isBlank()) return alphabetical - - // sortedBy is stable, so the alphabetical order survives within each rank. - return alphabetical.sortedBy { rankOf(query, it) } - } + ): List = + if (query.isBlank()) results.sortedWith(ByName) + else results.sortedWith(compareBy { rankOf(query, it) }.then(ByName)) private fun rankOf(query: String, result: LiteItemSearchResult): Int = when { result.name.equals(query, ignoreCase = true) -> EXACT_NAME @@ -36,5 +30,7 @@ class RankSearchResultsUseCase( const val USERNAME = 3 const val TAG = 4 const val NOTE = 5 + + val ByName = compareBy(NaturalOrderComparator) { it.name } } } diff --git a/feature/list_screen/src/test/kotlin/de/davis/keygo/feature/list_screen/domain/usecase/FilterUseCaseTest.kt b/feature/list_screen/src/test/kotlin/de/davis/keygo/feature/list_screen/domain/usecase/FilterUseCaseTest.kt index be52420d0..20110f2f9 100644 --- a/feature/list_screen/src/test/kotlin/de/davis/keygo/feature/list_screen/domain/usecase/FilterUseCaseTest.kt +++ b/feature/list_screen/src/test/kotlin/de/davis/keygo/feature/list_screen/domain/usecase/FilterUseCaseTest.kt @@ -8,7 +8,6 @@ import de.davis.keygo.core.item.domain.model.PasswordScore import de.davis.keygo.core.item.domain.model.Tag import de.davis.keygo.core.item.domain.model.lite.LiteItem import de.davis.keygo.core.item.generated.domain.model.VaultItemType -import de.davis.keygo.core.util.domain.usecase.SortUseCase import de.davis.keygo.feature.list_screen.domain.model.FilterFacet import de.davis.keygo.feature.list_screen.domain.model.FilterState import de.davis.keygo.feature.list_screen.domain.model.ItemAttributes @@ -20,7 +19,7 @@ import kotlin.test.assertTrue class FilterUseCaseTest { - private val useCase: FilterUseCase = FilterUseCase(SortUseCase()) + private val useCase: FilterUseCase = FilterUseCase() private val filterStateAsc = FilterState(sortDirection = SortDirection.Ascending) private val filterStateDesc = FilterState(sortDirection = SortDirection.Descending) @@ -63,14 +62,15 @@ class FilterUseCaseTest { fun `sorts items with numeric suffixes alphanumerically desc`() = sorting("AAA 8", "AAA 9", "AAA 10").descProduces("AAA 10", "AAA 9", "AAA 8") + // 02 and 2 compare as equal, so they keep their input order @Test fun `sorts items with leading zeros and multi-digit numbers`() = sorting("file10", "file02", "file2", "file1") - .ascProduces("file1", "file2", "file02", "file10") + .ascProduces("file1", "file02", "file2", "file10") @Test fun `sorts items with leading numbers`() = sorting("10file", "02file", "2file", "1file") - .ascProduces("1file", "2file", "02file", "10file") + .ascProduces("1file", "02file", "2file", "10file") @Test fun `filtering an empty list returns empty list`() { diff --git a/feature/list_screen/src/test/kotlin/de/davis/keygo/feature/list_screen/domain/usecase/ObserveFilterResultUseCaseTest.kt b/feature/list_screen/src/test/kotlin/de/davis/keygo/feature/list_screen/domain/usecase/ObserveFilterResultUseCaseTest.kt index 1cc289768..28168362d 100644 --- a/feature/list_screen/src/test/kotlin/de/davis/keygo/feature/list_screen/domain/usecase/ObserveFilterResultUseCaseTest.kt +++ b/feature/list_screen/src/test/kotlin/de/davis/keygo/feature/list_screen/domain/usecase/ObserveFilterResultUseCaseTest.kt @@ -18,7 +18,6 @@ import de.davis.keygo.core.item.domain.model.PasswordSecret import de.davis.keygo.core.item.domain.model.Tag import de.davis.keygo.core.item.domain.model.Timestamp import de.davis.keygo.core.item.passkeyRef -import de.davis.keygo.core.util.domain.usecase.SortUseCase import de.davis.keygo.feature.list_screen.domain.model.FilterFacet import de.davis.keygo.feature.list_screen.domain.model.FilterResult import de.davis.keygo.feature.list_screen.domain.model.FilterState @@ -40,12 +39,11 @@ class ObserveFilterResultUseCaseTest { private val loginRepository = FakeLoginRepository() private val itemRepository = FakeItemRepository(loginRepository) private val creditCardRepository = FakeCreditCardRepository() - private val sortUseCase = SortUseCase() private val useCase = ObserveFilterResultUseCase( itemRepository = itemRepository, loginRepository = loginRepository, - filterUseCase = FilterUseCase(sortUseCase), + filterUseCase = FilterUseCase(), observeCardExpiryStatuses = ObserveCardExpiryStatusesUseCase(creditCardRepository), availableFacetValues = AvailableFacetValuesUseCase(), ) diff --git a/feature/list_screen/src/test/kotlin/de/davis/keygo/feature/list_screen/domain/usecase/RankSearchResultsUseCaseTest.kt b/feature/list_screen/src/test/kotlin/de/davis/keygo/feature/list_screen/domain/usecase/RankSearchResultsUseCaseTest.kt index 851fd608f..0ec0d5ee7 100644 --- a/feature/list_screen/src/test/kotlin/de/davis/keygo/feature/list_screen/domain/usecase/RankSearchResultsUseCaseTest.kt +++ b/feature/list_screen/src/test/kotlin/de/davis/keygo/feature/list_screen/domain/usecase/RankSearchResultsUseCaseTest.kt @@ -2,14 +2,13 @@ package de.davis.keygo.feature.list_screen.domain.usecase import de.davis.keygo.core.item.domain.model.lite.LiteItemSearchResult import de.davis.keygo.core.item.generated.domain.model.VaultItemType -import de.davis.keygo.core.util.domain.usecase.SortUseCase import java.util.UUID import kotlin.test.Test import kotlin.test.assertEquals class RankSearchResultsUseCaseTest { - private val useCase = RankSearchResultsUseCase(SortUseCase()) + private val useCase = RankSearchResultsUseCase() private fun result( name: String, @@ -113,4 +112,16 @@ class RankSearchResultsUseCaseTest { assertEquals(listOf("alpha", "Bravo", "Charlie"), ordered) } + + @Test + fun `a whitespace-only query falls back to plain alphabetical order`() { + val ordered = rank( + " ", + result("Charlie"), + result("Zulu name", matchedName = true), + result("Bravo"), + ) + + assertEquals(listOf("Bravo", "Charlie", "Zulu name"), ordered) + } } diff --git a/feature/list_screen/src/test/kotlin/de/davis/keygo/feature/list_screen/presentation/ItemListViewModelTest.kt b/feature/list_screen/src/test/kotlin/de/davis/keygo/feature/list_screen/presentation/ItemListViewModelTest.kt index afb01dc4e..ce89abc74 100644 --- a/feature/list_screen/src/test/kotlin/de/davis/keygo/feature/list_screen/presentation/ItemListViewModelTest.kt +++ b/feature/list_screen/src/test/kotlin/de/davis/keygo/feature/list_screen/presentation/ItemListViewModelTest.kt @@ -17,7 +17,6 @@ import de.davis.keygo.core.item.domain.model.Login import de.davis.keygo.core.item.domain.model.Tag import de.davis.keygo.core.item.domain.model.Timestamp import de.davis.keygo.core.item.domain.usecase.ObserveAllTagsSortedUseCase -import de.davis.keygo.core.util.domain.usecase.SortUseCase import de.davis.keygo.feature.list_screen.domain.model.FilterFacet import de.davis.keygo.feature.list_screen.domain.usecase.AvailableFacetValuesUseCase import de.davis.keygo.feature.list_screen.domain.usecase.FilterUseCase @@ -63,7 +62,6 @@ class ItemListViewModelTest { private val vaultRepository = FakeVaultRepository() private val vaultContextRepository = FakeVaultContextRepository() private val creditCardRepository = FakeCreditCardRepository() - private val sortUseCase = SortUseCase() @BeforeTest fun setUp() = Dispatchers.setMain(dispatcher) @@ -75,17 +73,16 @@ class ItemListViewModelTest { enableSelection = enableSelection, restrictedItemType = null, itemRepository = itemRepository, - rankSearchResults = RankSearchResultsUseCase(sortUseCase), - observeAllTags = ObserveAllTagsSortedUseCase(itemRepository, sortUseCase), + rankSearchResults = RankSearchResultsUseCase(), + observeAllTags = ObserveAllTagsSortedUseCase(itemRepository), observeVaultsAndSelection = ObserveVaultsAndSelectionUseCase( vaultRepository = vaultRepository, vaultContextRepository = vaultContextRepository, - sortUseCase = sortUseCase, ), observeFilterResult = ObserveFilterResultUseCase( itemRepository = itemRepository, loginRepository = loginRepository, - filterUseCase = FilterUseCase(sortUseCase), + filterUseCase = FilterUseCase(), observeCardExpiryStatuses = ObserveCardExpiryStatusesUseCase(creditCardRepository), availableFacetValues = AvailableFacetValuesUseCase(), ), diff --git a/feature/password-health/build.gradle.kts b/feature/password-health/build.gradle.kts new file mode 100644 index 000000000..daf56b254 --- /dev/null +++ b/feature/password-health/build.gradle.kts @@ -0,0 +1,37 @@ +plugins { + alias(libs.plugins.keygo.android.compose) + alias(libs.plugins.keygo.android.protobuf) + alias(libs.plugins.kotlin.serialization) +} + +android { + namespace = "de.davis.keygo.feature.password_health" + + defaultConfig { + missingDimensionStrategy("store", "playStore") + } +} + +dependencies { + implementation(projects.core.ui) + implementation(projects.core.item) + implementation(projects.core.util) + implementation(projects.core.security) + implementation(projects.feature.item.core) + implementation(projects.feature.item.create) + implementation(projects.feature.item.view) + + implementation(libs.okhttp) + implementation(libs.gosimple.nbvcxz) + + implementation(libs.androidx.datastore) + implementation(libs.androidx.work) + implementation(libs.koin.androidx.workmanager) + implementation(libs.com.google.accompanist.permissions) + + testImplementation(testFixtures(projects.core.util)) + testImplementation(testFixtures(projects.core.item)) + testImplementation(testFixtures(projects.core.security)) + testImplementation(testFixtures(projects.rust)) + testImplementation(libs.robolectric) +} diff --git a/feature/password-health/consumer-rules.pro b/feature/password-health/consumer-rules.pro new file mode 100644 index 000000000..e69de29bb diff --git a/feature/password-health/src/main/AndroidManifest.xml b/feature/password-health/src/main/AndroidManifest.xml new file mode 100644 index 000000000..ae03cb5ce --- /dev/null +++ b/feature/password-health/src/main/AndroidManifest.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/data/HealthCheckNotifierSchedulerImpl.kt b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/data/HealthCheckNotifierSchedulerImpl.kt new file mode 100644 index 000000000..0ce611e39 --- /dev/null +++ b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/data/HealthCheckNotifierSchedulerImpl.kt @@ -0,0 +1,37 @@ +package de.davis.keygo.feature.password_health.data + +import androidx.work.ExistingPeriodicWorkPolicy +import androidx.work.PeriodicWorkRequestBuilder +import androidx.work.WorkManager +import de.davis.keygo.feature.password_health.domain.HealthCheckNotifierScheduler +import de.davis.keygo.feature.password_health.worker.HealthCheckNotifier +import org.koin.core.annotation.Single +import kotlin.time.Duration.Companion.days +import kotlin.time.toJavaDuration + +@Single +internal class HealthCheckNotifierSchedulerImpl( + private val workManager: WorkManager, +) : HealthCheckNotifierScheduler { + + override fun scheduleHealthReminder() { + // Delayed so turning reminders on does not immediately repeat the screen the user is on. + val request = PeriodicWorkRequestBuilder(REPEAT_INTERVAL) + .setInitialDelay(REPEAT_INTERVAL) + .build() + + workManager.enqueueUniquePeriodicWork( + uniqueWorkName = HealthCheckNotifier.UNIQUE_WORK_NAME, + existingPeriodicWorkPolicy = ExistingPeriodicWorkPolicy.UPDATE, + request = request, + ) + } + + override fun cancel() { + workManager.cancelUniqueWork(HealthCheckNotifier.UNIQUE_WORK_NAME) + } + + companion object { + private val REPEAT_INTERVAL = 7.days.toJavaDuration() + } +} diff --git a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/data/LoginFingerprinterImpl.kt b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/data/LoginFingerprinterImpl.kt new file mode 100644 index 000000000..81632d4dc --- /dev/null +++ b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/data/LoginFingerprinterImpl.kt @@ -0,0 +1,43 @@ +package de.davis.keygo.feature.password_health.data + +import de.davis.keygo.core.item.domain.model.Login +import de.davis.keygo.feature.password_health.domain.LoginFingerprinter +import de.davis.keygo.feature.password_health.domain.model.HealthFingerprint +import kotlinx.coroutines.withContext +import org.koin.core.annotation.Single +import java.nio.ByteBuffer +import java.security.MessageDigest +import kotlin.coroutines.CoroutineContext +import kotlin.uuid.toKotlinUuid + +@Single +internal class LoginFingerprinterImpl : LoginFingerprinter { + + override suspend fun fingerprint( + login: Login, + coroutineContext: CoroutineContext + ): HealthFingerprint? = withContext(coroutineContext) { + val payload = login.passwordCredential?.secret?.payload ?: return@withContext null + + val digest = MessageDigest.getInstance("SHA-256") + + digest.update(login.id.toKotlinUuid().toByteArray()) + digest.updateWithPrefix(payload.ciphertext) + digest.updateWithPrefix(payload.iv) + + HealthFingerprint(digest.digest()) + } + + private fun MessageDigest.updateWithPrefix(data: ByteArray) { + update( + ByteBuffer.allocate(LENGTH_PREFIX_BYTES) + .putInt(data.size) + .array(), + ) + update(data) + } + + companion object { + private const val LENGTH_PREFIX_BYTES = 4 + } +} diff --git a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/data/NotifierImpl.kt b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/data/NotifierImpl.kt new file mode 100644 index 000000000..e4d0169d1 --- /dev/null +++ b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/data/NotifierImpl.kt @@ -0,0 +1,124 @@ +package de.davis.keygo.feature.password_health.data + +import android.Manifest +import android.app.Notification +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import android.os.Build +import androidx.annotation.StringRes +import androidx.core.app.NotificationChannelCompat +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import androidx.core.content.ContextCompat +import de.davis.keygo.feature.password_health.R +import de.davis.keygo.feature.password_health.domain.Notifier +import de.davis.keygo.feature.password_health.domain.model.KeyGoNotification +import de.davis.keygo.feature.password_health.presentation.ACTION_OPEN_PASSWORD_HEALTH +import org.koin.core.annotation.Single +import de.davis.keygo.core.ui.R as CoreUiR + +@Single +internal class NotifierImpl( + private val context: Context, +) : Notifier { + + private val notificationManagerCompat by lazy { NotificationManagerCompat.from(context) } + + override fun canNotify(): Boolean { + val permissionGranted = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) + ContextCompat.checkSelfPermission( + context, + Manifest.permission.POST_NOTIFICATIONS, + ) == PackageManager.PERMISSION_GRANTED + else true + + return permissionGranted && notificationManagerCompat.areNotificationsEnabled() + } + + @Suppress("MissingPermission") + override fun sendNotification(notification: KeyGoNotification) { + if (!canNotify()) return + + createChannels() + notificationManagerCompat.notify(notification.kind.id, notification.buildNotification()) + } + + override fun cancel(kind: KeyGoNotification.Kind) { + notificationManagerCompat.cancel(kind.id) + } + + private fun createChannels() { + val channels = Channel.entries.map { + NotificationChannelCompat.Builder(it.id, it.importance) + .setName(context.getString(it.nameRes)) + .setDescription(context.getString(it.descriptionRes)) + .build() + } + notificationManagerCompat.createNotificationChannelsCompat(channels) + } + + private fun KeyGoNotification.buildNotification(): Notification = when (this) { + is KeyGoNotification.NeedsAttention -> baseNotification() + .setContentText( + context.resources.getQuantityString( + R.plurals.password_health_needs_attention, + count, + count, + ), + ) + .setVisibility(NotificationCompat.VISIBILITY_PRIVATE) + .setPublicVersion(baseNotification().build()) + .setCategory(NotificationCompat.CATEGORY_RECOMMENDATION) + .setOnlyAlertOnce(true) + .setAutoCancel(true) + .setContentIntent(openPasswordHealthIntent()) + .build() + } + + private fun KeyGoNotification.baseNotification() = + NotificationCompat.Builder(context, channel.id) + .setSmallIcon(CoreUiR.drawable.ic_app) + .setContentTitle(context.getString(R.string.password_health_title)) + + private fun openPasswordHealthIntent(): PendingIntent? { + val intent = context.packageManager.getLaunchIntentForPackage(context.packageName)?.apply { + action = ACTION_OPEN_PASSWORD_HEALTH + + // Without these a running task gets a second MainActivity instead of onNewIntent. + addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP) + } ?: return null + + return PendingIntent.getActivity( + context, + 0, + intent, + PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT, + ) + } + + private val KeyGoNotification.channel: Channel + get() = when (kind) { + KeyGoNotification.Kind.NeedsAttention -> Channel.PasswordHealth + } + + private val KeyGoNotification.Kind.id: Int + get() = when (this) { + KeyGoNotification.Kind.NeedsAttention -> 1001 + } + + private enum class Channel( + val id: String, + val importance: Int, + @param:StringRes val nameRes: Int, + @param:StringRes val descriptionRes: Int, + ) { + PasswordHealth( + id = "password_health_channel", + importance = NotificationManagerCompat.IMPORTANCE_LOW, + nameRes = R.string.password_health_notification_channel_name, + descriptionRes = R.string.password_health_notification_channel_description, + ), + } +} diff --git a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/data/mapper/BreachCheckStateMapper.kt b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/data/mapper/BreachCheckStateMapper.kt new file mode 100644 index 000000000..45cb29ee9 --- /dev/null +++ b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/data/mapper/BreachCheckStateMapper.kt @@ -0,0 +1,9 @@ +package de.davis.keygo.feature.password_health.data.mapper + +import de.davis.keygo.feature.password_health.data.local.model.ProtoHealthSettings +import de.davis.keygo.feature.password_health.domain.model.HealthSettings + +internal fun ProtoHealthSettings.toDomain() = HealthSettings( + breachesEnabled = breachesEnabled, + notificationsEnabled = notificationsEnabled +) diff --git a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/data/mapper/HealthNotificationStateMapper.kt b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/data/mapper/HealthNotificationStateMapper.kt new file mode 100644 index 000000000..e3f421f7f --- /dev/null +++ b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/data/mapper/HealthNotificationStateMapper.kt @@ -0,0 +1,42 @@ +package de.davis.keygo.feature.password_health.data.mapper + +import com.google.protobuf.kotlin.toByteString +import de.davis.keygo.feature.password_health.data.local.model.ProtoHealthNotificationState +import de.davis.keygo.feature.password_health.data.local.model.ProtoHealthReminder +import de.davis.keygo.feature.password_health.data.local.model.ProtoHealthSnapshot +import de.davis.keygo.feature.password_health.domain.model.HealthNotificationState +import de.davis.keygo.feature.password_health.domain.model.HealthReminder +import de.davis.keygo.feature.password_health.domain.model.HealthSnapshot +import de.davis.keygo.feature.password_health.domain.model.VaultFingerprint +import kotlin.time.Instant + +internal fun ProtoHealthNotificationState.toDomain() = HealthNotificationState( + snapshot = if (hasSnapshot()) snapshot.toDomain() else null, + reminder = if (hasReminder()) reminder.toDomain() else null, +) + +internal fun ProtoHealthSnapshot.toDomain() = HealthSnapshot( + attentionCount = attentionCount, + breachedCount = breachedCount, + vault = VaultFingerprint(vaultFingerprint.toByteArray()), +) + +internal fun ProtoHealthReminder.toDomain() = HealthReminder( + attentionCount = attentionCount, + breachedCount = breachedCount, + sentAt = Instant.fromEpochMilliseconds(sentAtEpochMillis), + repeats = repeats, +) + +internal fun HealthSnapshot.toProto(): ProtoHealthSnapshot = ProtoHealthSnapshot.newBuilder() + .setAttentionCount(attentionCount) + .setBreachedCount(breachedCount) + .setVaultFingerprint(vault.value.toByteString()) + .build() + +internal fun HealthReminder.toProto(): ProtoHealthReminder = ProtoHealthReminder.newBuilder() + .setAttentionCount(attentionCount) + .setBreachedCount(breachedCount) + .setSentAtEpochMillis(sentAt.toEpochMilliseconds()) + .setRepeats(repeats) + .build() diff --git a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/data/mapper/StoredHealthReportMapper.kt b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/data/mapper/StoredHealthReportMapper.kt new file mode 100644 index 000000000..ac36c3e26 --- /dev/null +++ b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/data/mapper/StoredHealthReportMapper.kt @@ -0,0 +1,216 @@ +package de.davis.keygo.feature.password_health.data.mapper + +import android.util.Log +import com.google.protobuf.kotlin.toByteString +import de.davis.keygo.core.item.domain.alias.ItemId +import de.davis.keygo.core.item.domain.model.PasswordScore +import de.davis.keygo.feature.password_health.data.local.model.ProtoCheckKind +import de.davis.keygo.feature.password_health.data.local.model.ProtoGapReason +import de.davis.keygo.feature.password_health.data.local.model.ProtoPasswordScore +import de.davis.keygo.feature.password_health.data.local.model.ProtoRelationFinding +import de.davis.keygo.feature.password_health.data.local.model.ProtoRelationType +import de.davis.keygo.feature.password_health.data.local.model.ProtoStoredHealthReport +import de.davis.keygo.feature.password_health.data.local.model.ProtoStoredItem +import de.davis.keygo.feature.password_health.data.local.model.protoCheckGap +import de.davis.keygo.feature.password_health.data.local.model.protoRelationFinding +import de.davis.keygo.feature.password_health.data.local.model.protoStoredHealthReport +import de.davis.keygo.feature.password_health.data.local.model.protoStoredItem +import de.davis.keygo.feature.password_health.domain.model.BreachResult +import de.davis.keygo.feature.password_health.domain.model.CheckGap +import de.davis.keygo.feature.password_health.domain.model.CheckKind +import de.davis.keygo.feature.password_health.domain.model.GapReason +import de.davis.keygo.feature.password_health.domain.model.HealthFinding +import de.davis.keygo.feature.password_health.domain.model.HealthFingerprint +import de.davis.keygo.feature.password_health.domain.model.RelationType +import de.davis.keygo.feature.password_health.domain.model.StoredHealthItem +import de.davis.keygo.feature.password_health.domain.model.StoredHealthReport +import java.io.ByteArrayInputStream +import java.io.ByteArrayOutputStream +import java.util.zip.GZIPInputStream +import java.util.zip.GZIPOutputStream +import kotlin.time.Instant +import kotlin.uuid.Uuid +import kotlin.uuid.toJavaUuid +import kotlin.uuid.toKotlinUuid + +internal fun StoredHealthReport.toCompressedByteArray(): ByteArray = + ByteArrayOutputStream().also { bos -> + GZIPOutputStream(bos).use { it.write(toProto().toByteArray()) } + }.toByteArray() + +internal fun ByteArray.toStoredHealthReport(): StoredHealthReport? = + runCatching { + ProtoStoredHealthReport.parseFrom(GZIPInputStream(ByteArrayInputStream(this)).use { it.readBytes() }) + .toDomain() + }.getOrNull() + +private fun ProtoStoredHealthReport.toDomain(): StoredHealthReport? { + if (algorithmVersion != StoredHealthReport.ALGORITHM_VERSION) return null + + val items = itemsList.map(ProtoStoredItem::toDomain) + + fun List.toItemIds(): Set? { + return mapTo(HashSet(size)) { ref -> + items.getOrNull(ref)?.id ?: run { + Log.w("StoredHealthReportMapper", "Ref $ref out of range (${items.size} items)") + return null + } + } + } + + val gaps = HashMap(gapsCount) + gapsList.forEach { gap -> + val kind = gap.kind.toDomain() + val reason = gap.reason.toDomain() + if (kind == null || reason == null) return@forEach + + val gapValue = CheckGap( + reason = reason, + unchecked = gap.uncheckedItemRefsList.toItemIds() ?: return null, + ) + if (gaps.put(kind, gapValue) != null) { + Log.w("StoredHealthReportMapper", "Duplicate gap for $kind") + return null + } + } + + return StoredHealthReport( + items = items, + relationalFindings = relationFindingList.mapNotNull { + it.toDomain( + it.itemRefsList.toItemIds() ?: return null + ) + }, + gaps = gaps, + breachCheckEnabled = breachCheckEnabled, + ) +} + +private fun StoredHealthReport.toProto(): ProtoStoredHealthReport { + val refOf = HashMap(items.size) + items.forEachIndexed { index, item -> + check(refOf.put(item.id, index) == null) { "Duplicate item ${item.id}" } + } + fun Set.toRefs(): List = map { id -> + refOf[id] ?: error("Finding references $id, which is not in items") + } + + return protoStoredHealthReport { + items += this@toProto.items.map(StoredHealthItem::toProto) + relationFinding += this@toProto.relationalFindings.map { it.toProto(it.relatedItemIds.toRefs()) } + gaps += this@toProto.gaps.toProto { it.unchecked.toRefs() } + breachCheckEnabled = this@toProto.breachCheckEnabled + algorithmVersion = StoredHealthReport.ALGORITHM_VERSION + } +} + +private fun StoredHealthItem.toProto() = protoStoredItem { + id = this@toProto.id.toProtoByteString() + fingerprint = this@toProto.fingerprint.value.toByteString() + score = this@toProto.score.toProto() + + breach?.let { breach -> + if (breach.occurrences > 0) breachOccurrences = breach.occurrences + breachCheckedAtEpochMillis = breach.checkedAt.toEpochMilliseconds() + } + + unreadable = this@toProto.unreadable +} + +private fun ProtoStoredItem.toDomain(): StoredHealthItem = StoredHealthItem( + id = Uuid.fromByteArray(id.toByteArray()).toJavaUuid(), + fingerprint = HealthFingerprint(fingerprint.toByteArray()), + score = score.toDomain(), + breach = if (hasBreachCheckedAtEpochMillis()) BreachResult( + occurrences = breachOccurrences, + checkedAt = Instant.fromEpochMilliseconds(breachCheckedAtEpochMillis), + ) else null, + unreadable = unreadable, +) + +private fun HealthFinding.Relation.toProto(refs: List) = protoRelationFinding { + itemRefs += refs + type = this@toProto.type.toProto() +} + +private fun ProtoRelationFinding.toDomain(relatedItemIds: Set) = type.toDomain()?.let { + HealthFinding.Relation( + relatedItemIds = relatedItemIds, + type = it, + ) +} + +private fun Map.toProto(refs: (CheckGap) -> List) = + map { (checkKind, gap) -> + protoCheckGap { + kind = checkKind.toProto() + reason = gap.reason.toProto() + uncheckedItemRefs += refs(gap) + } + } + +private fun PasswordScore?.toProto() = when (this) { + null, + PasswordScore.None -> ProtoPasswordScore.PASSWORD_SCORE_NONE + + PasswordScore.Ridiculous -> ProtoPasswordScore.PASSWORD_SCORE_RIDICULOUS + PasswordScore.Weak -> ProtoPasswordScore.PASSWORD_SCORE_WEAK + PasswordScore.Moderate -> ProtoPasswordScore.PASSWORD_SCORE_MODERATE + PasswordScore.Strong -> ProtoPasswordScore.PASSWORD_SCORE_STRONG + PasswordScore.Excellent -> ProtoPasswordScore.PASSWORD_SCORE_EXCELLENT +} + +private fun ProtoPasswordScore.toDomain() = when (this) { + ProtoPasswordScore.UNRECOGNIZED, + ProtoPasswordScore.PASSWORD_SCORE_NONE -> null + + ProtoPasswordScore.PASSWORD_SCORE_RIDICULOUS -> PasswordScore.Ridiculous + ProtoPasswordScore.PASSWORD_SCORE_WEAK -> PasswordScore.Weak + ProtoPasswordScore.PASSWORD_SCORE_MODERATE -> PasswordScore.Moderate + ProtoPasswordScore.PASSWORD_SCORE_STRONG -> PasswordScore.Strong + ProtoPasswordScore.PASSWORD_SCORE_EXCELLENT -> PasswordScore.Excellent +} + +private fun RelationType.toProto() = when (this) { + RelationType.Reused -> ProtoRelationType.RELATION_TYPE_REUSED + RelationType.Similar -> ProtoRelationType.RELATION_TYPE_SIMILAR +} + +private fun ProtoRelationType.toDomain() = when (this) { + ProtoRelationType.UNRECOGNIZED -> null + + ProtoRelationType.RELATION_TYPE_REUSED -> RelationType.Reused + ProtoRelationType.RELATION_TYPE_SIMILAR -> RelationType.Similar +} + +private fun CheckKind.toProto() = when (this) { + CheckKind.Breach -> ProtoCheckKind.CHECK_KIND_BREACH + CheckKind.Reuse -> ProtoCheckKind.CHECK_KIND_REUSE + CheckKind.Strength -> ProtoCheckKind.CHECK_KIND_STRENGTH + CheckKind.Similarity -> ProtoCheckKind.CHECK_KIND_SIMILARITY +} + +private fun ProtoCheckKind.toDomain() = when (this) { + ProtoCheckKind.UNRECOGNIZED -> null + + ProtoCheckKind.CHECK_KIND_BREACH -> CheckKind.Breach + ProtoCheckKind.CHECK_KIND_REUSE -> CheckKind.Reuse + ProtoCheckKind.CHECK_KIND_STRENGTH -> CheckKind.Strength + ProtoCheckKind.CHECK_KIND_SIMILARITY -> CheckKind.Similarity +} + +private fun GapReason.toProto() = when (this) { + GapReason.Disabled -> ProtoGapReason.GAP_REASON_DISABLED + GapReason.Failed -> ProtoGapReason.GAP_REASON_FAILED + GapReason.Unreachable -> ProtoGapReason.GAP_REASON_UNREACHABLE +} + +private fun ProtoGapReason.toDomain() = when (this) { + ProtoGapReason.UNRECOGNIZED -> null + + ProtoGapReason.GAP_REASON_DISABLED -> GapReason.Disabled + ProtoGapReason.GAP_REASON_FAILED -> GapReason.Failed + ProtoGapReason.GAP_REASON_UNREACHABLE -> GapReason.Unreachable +} + +private fun ItemId.toProtoByteString() = toKotlinUuid().toByteArray().toByteString() diff --git a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/data/repository/BreachedRepositoryImpl.kt b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/data/repository/BreachedRepositoryImpl.kt new file mode 100644 index 000000000..66e4d34d6 --- /dev/null +++ b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/data/repository/BreachedRepositoryImpl.kt @@ -0,0 +1,161 @@ +package de.davis.keygo.feature.password_health.data.repository + +import android.util.Log +import de.davis.keygo.core.util.Result +import de.davis.keygo.feature.password_health.data.repository.BreachedRepositoryImpl.Companion.MAX_RANGE_LINES +import de.davis.keygo.feature.password_health.di.annotation.BreachedQualifier +import de.davis.keygo.feature.password_health.domain.model.BreachedError +import de.davis.keygo.feature.password_health.domain.repository.BreachedRepository +import de.davis.keygo.feature.password_health.domain.repository.BreachedRepository.Companion.PREFIX_LENGTH +import kotlinx.coroutines.delay +import kotlinx.coroutines.suspendCancellableCoroutine +import okhttp3.Call +import okhttp3.Callback +import okhttp3.HttpUrl.Companion.toHttpUrl +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.Response +import okio.BufferedSource +import org.koin.core.annotation.Single +import java.io.IOException +import kotlin.coroutines.resume +import kotlin.time.Duration +import kotlin.time.Duration.Companion.seconds + +@Single +internal class BreachedRepositoryImpl( + @param:BreachedQualifier + private val http: OkHttpClient, +) : BreachedRepository { + + override suspend fun occurrences( + prefix: String, + suffixes: Set, + ): Result, BreachedError> { + if (!prefix.isRangePrefix()) { + Log.w(TAG, "Refusing to ask for a range that is not $PREFIX_LENGTH hex characters") + return Result.Failure(BreachedError.InvalidPrefix) + } + + if (suffixes.isEmpty()) return Result.Success(emptyMap()) + + val request = Request.Builder() + .url(RANGE_URL.toHttpUrl().newBuilder().addPathSegment(prefix).build()) + .header("Add-Padding", "true") + .header("Cache-Control", "no-store") + .build() + + return request.fetch(suffixes) + } + + private suspend fun Request.fetch( + suffixes: Set, + ): Result, BreachedError> { + var attempt = 1 + + while (true) { + when (val outcome = send(suffixes)) { + is Attempt.Answered -> return Result.Success(outcome.counts) + is Attempt.Failed -> return Result.Failure(outcome.error) + + is Attempt.Throttled -> { + if (attempt == MAX_ATTEMPTS) { + Log.w(TAG, "The breach API still throttled us after $attempt attempts") + return Result.Failure(BreachedError.ApiFailed) + } + + attempt++ + delay(outcome.backoff) + } + } + } + } + + private suspend fun Request.send(suffixes: Set): Attempt = + suspendCancellableCoroutine { cont -> + val call = http.newCall(this) + cont.invokeOnCancellation { call.cancel() } + + call.enqueue(object : Callback { + override fun onFailure(call: Call, e: IOException) { + Log.w(TAG, "Could not reach the breach API", e) + cont.resume(Attempt.Failed(BreachedError.Unreachable)) + } + + override fun onResponse(call: Call, response: Response) { + cont.resume(response.use { it.read(suffixes) }) + } + }) + } + + private fun Response.read(suffixes: Set): Attempt { + if (code == HTTP_TOO_MANY_REQUESTS) return Attempt.Throttled(backoff()) + + if (!isSuccessful) { + Log.w(TAG, "The breach API answered with $code") + return Attempt.Failed(BreachedError.ApiFailed) + } + + val counts = body.source().readCounts(suffixes) + if (counts == null) { + Log.w(TAG, "The range did not end within $MAX_RANGE_LINES lines") + return Attempt.Failed(BreachedError.ApiFailed) + } + + return Attempt.Answered(counts) + } + + private fun Response.backoff(): Duration = header("Retry-After") + ?.toLongOrNull() + ?.seconds + ?.coerceIn(MIN_BACKOFF, MAX_BACKOFF) + ?: MIN_BACKOFF + + /** Null when the range ran past [MAX_RANGE_LINES] without ending. */ + private fun BufferedSource.readCounts(suffixes: Set): Map? { + val counts = mutableMapOf() + var lines = 0 + + while (lines++ < MAX_RANGE_LINES) { + val line = readUtf8Line() ?: return counts + + val separator = line.indexOf(':') + if (separator <= 0) continue + + // The matched suffix is reused as the key, so a line we care about is the only thing + // in the response that turns into a string. + val suffix = suffixes.firstOrNull { + it.length == separator && line.startsWith(it, ignoreCase = true) + } ?: continue + + val count = line.substring(separator + 1).trim().toIntOrNull() ?: continue + if (count > 0) counts[suffix] = count + } + + // What we read is not an answer: the suffix we asked about could be past the cap, and + // reporting that as a clean range is the one mistake this check must not make. + return null + } + + private fun String.isRangePrefix() = + length == PREFIX_LENGTH && all { it.digitToIntOrNull(radix = 16) != null } + + private sealed interface Attempt { + data class Answered(val counts: Map) : Attempt + data class Throttled(val backoff: Duration) : Attempt + data class Failed(val error: BreachedError) : Attempt + } + + companion object { + private const val TAG = "BreachedRepositoryImpl" + + private const val RANGE_URL = "https://api.pwnedpasswords.com/range/" + private const val HTTP_TOO_MANY_REQUESTS = 429 + + private const val MAX_ATTEMPTS = 3 + private val MIN_BACKOFF = 1.seconds + private val MAX_BACKOFF = 10.seconds + + private const val MAX_RANGE_LINES = 8_192 + } +} diff --git a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/data/repository/ConnectivityRepositoryImpl.kt b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/data/repository/ConnectivityRepositoryImpl.kt new file mode 100644 index 000000000..f7f5f3165 --- /dev/null +++ b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/data/repository/ConnectivityRepositoryImpl.kt @@ -0,0 +1,74 @@ +package de.davis.keygo.feature.password_health.data.repository + +import android.content.Context +import android.net.ConnectivityManager +import android.net.Network +import android.net.NetworkCapabilities +import androidx.core.content.getSystemService +import de.davis.keygo.core.util.di.annotation.AppScopeQualifier +import de.davis.keygo.feature.password_health.domain.repository.ConnectivityRepository +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.callbackFlow +import kotlinx.coroutines.flow.conflate +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.shareIn +import org.koin.core.annotation.Single + +@Single +internal class ConnectivityRepositoryImpl( + context: Context, + @AppScopeQualifier appScope: CoroutineScope, +) : ConnectivityRepository { + + private val connectivity = requireNotNull( + context.applicationContext.getSystemService() + ) + + private val internet = callbackFlow { + val callback = object : ConnectivityManager.NetworkCallback() { + private var current: Network? = null + + override fun onCapabilitiesChanged( + network: Network, + networkCapabilities: NetworkCapabilities, + ) { + current = network + trySend(networkCapabilities.hasInternet()) + } + + // A handover can report the old default as lost after the new one is already up. + override fun onLost(network: Network) { + if (network != current) return + + current = null + trySend(false) + } + } + + trySend(connectivity.isCurrentlyConnected()) + + connectivity.registerDefaultNetworkCallback(callback) + awaitClose { connectivity.unregisterNetworkCallback(callback) } + } + .conflate() + .distinctUntilChanged() + // A cached value would outlive the callback that kept it current. + .shareIn(appScope, SharingStarted.WhileSubscribed(replayExpirationMillis = 0), replay = 1) + + override fun observeInternet(): Flow = internet + + private fun ConnectivityManager.isCurrentlyConnected(): Boolean = + activeNetwork + ?.let(::getNetworkCapabilities) + ?.hasInternet() + ?: false + + // Validated rules out a network that is joined but has no way out, which is where every + // lookup would otherwise hang until its timeout. + private fun NetworkCapabilities.hasInternet(): Boolean = + hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) && + hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED) +} diff --git a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/data/repository/HealthNotificationStateRepositoryImpl.kt b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/data/repository/HealthNotificationStateRepositoryImpl.kt new file mode 100644 index 000000000..5b79e7bbe --- /dev/null +++ b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/data/repository/HealthNotificationStateRepositoryImpl.kt @@ -0,0 +1,40 @@ +package de.davis.keygo.feature.password_health.data.repository + +import androidx.datastore.core.DataStore +import de.davis.keygo.feature.password_health.data.local.model.ProtoHealthNotificationState +import de.davis.keygo.feature.password_health.data.mapper.toDomain +import de.davis.keygo.feature.password_health.data.mapper.toProto +import de.davis.keygo.feature.password_health.di.annotation.HealthNotificationStateQualifier +import de.davis.keygo.feature.password_health.domain.model.HealthNotificationState +import de.davis.keygo.feature.password_health.domain.model.HealthReminder +import de.davis.keygo.feature.password_health.domain.model.HealthSnapshot +import de.davis.keygo.feature.password_health.domain.repository.HealthNotificationStateRepository +import kotlinx.coroutines.flow.first +import org.koin.core.annotation.Single + +@Single +internal class HealthNotificationStateRepositoryImpl( + @HealthNotificationStateQualifier + private val dataStore: DataStore, +) : HealthNotificationStateRepository { + + override suspend fun get(): HealthNotificationState = dataStore.data.first().toDomain() + + override suspend fun setSnapshot(snapshot: HealthSnapshot?) { + dataStore.updateData { + if (snapshot == null) it.toBuilder().clearSnapshot().build() + else it.toBuilder().setSnapshot(snapshot.toProto()).build() + } + } + + override suspend fun setReminder(reminder: HealthReminder?) { + dataStore.updateData { + if (reminder == null) it.toBuilder().clearReminder().build() + else it.toBuilder().setReminder(reminder.toProto()).build() + } + } + + override suspend fun clear() { + dataStore.updateData { ProtoHealthNotificationState.getDefaultInstance() } + } +} diff --git a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/data/repository/HealthReportStoreRepositoryImpl.kt b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/data/repository/HealthReportStoreRepositoryImpl.kt new file mode 100644 index 000000000..f9f424e4d --- /dev/null +++ b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/data/repository/HealthReportStoreRepositoryImpl.kt @@ -0,0 +1,93 @@ +package de.davis.keygo.feature.password_health.data.repository + +import androidx.datastore.core.DataStore +import com.google.protobuf.kotlin.isNotEmpty +import com.google.protobuf.kotlin.toByteString +import de.davis.keygo.core.item.domain.model.KeyInformation +import de.davis.keygo.core.security.domain.crypto.CryptographicScopeProvider +import de.davis.keygo.core.security.domain.crypto.model.CryptographicData +import de.davis.keygo.core.util.Result +import de.davis.keygo.core.util.asResult +import de.davis.keygo.core.util.resultBinding +import de.davis.keygo.feature.password_health.data.local.model.ProtoHealthReportStore +import de.davis.keygo.feature.password_health.data.mapper.toCompressedByteArray +import de.davis.keygo.feature.password_health.data.mapper.toStoredHealthReport +import de.davis.keygo.feature.password_health.di.annotation.HealthReportStore +import de.davis.keygo.feature.password_health.domain.model.HealthReportStoreError +import de.davis.keygo.feature.password_health.domain.model.StoredHealthReport +import de.davis.keygo.feature.password_health.domain.repository.HealthReportStoreRepository +import de.davisalessandro.keygo.rust.ItemCryptoException +import kotlinx.coroutines.flow.first +import org.koin.core.annotation.Single +import java.util.UUID + +@Single +internal class HealthReportStoreRepositoryImpl( + @HealthReportStore + private val dataStore: DataStore, + private val cryptographicScopeProvider: CryptographicScopeProvider, +) : HealthReportStoreRepository { + + override suspend fun storeReport(report: StoredHealthReport): Result = + resultBinding { + val stored = dataStore.data.first() + .takeIf { it.wrappedKey.isNotEmpty() } + + val compressedReport = report.toCompressedByteArray() + + val (sealedReport, key) = cryptographicScopeProvider.accountScope( + namespace = REPORT_NAMESPACE, + wrapped = stored?.toWrappedKey(), + ) { + compressedReport.encrypt(REPORT_LABEL) to wrapCurrentItemKey() + }.bind { HealthReportStoreError.CryptoFailed } + + dataStore.updateData { + it.toBuilder() + .setWrappedKey(key.wrappedKey.toByteString()) + .setWrappedKeyNonce(key.keyNonce.toByteString()) + .setCiphertext(sealedReport.data.toByteString()) + .setIv(sealedReport.iv.toByteString()) + .build() + } + } + + override suspend fun load(): Result = + resultBinding { + val stored = dataStore.data.first() + if (stored.wrappedKey.isEmpty || stored.ciphertext.isEmpty) { + return Result.Failure(HealthReportStoreError.NoReportStored) + } + + val compressedReport = cryptographicScopeProvider.accountScope( + namespace = REPORT_NAMESPACE, + wrapped = stored.toWrappedKey(), + ) { + try { + CryptographicData( + data = stored.ciphertext.toByteArray(), + iv = stored.iv.toByteArray(), + ).decrypt(REPORT_LABEL) + } catch (_: ItemCryptoException) { + null + } + }.bind { HealthReportStoreError.CryptoFailed } + + compressedReport?.toStoredHealthReport() + .asResult(HealthReportStoreError.CorruptedReport) + .bind() + } + + private fun ProtoHealthReportStore.toWrappedKey() = KeyInformation( + wrappedKey = wrappedKey.toByteArray(), + keyNonce = wrappedKeyNonce.toByteArray(), + ) + + companion object { + // On-disk identity: the AAD domain separator for every stored report. Never change it; + // doing so makes every previously stored report permanently undecryptable. + private val REPORT_NAMESPACE: UUID = UUID.fromString("39050172-ce6d-4f4d-8787-6e12ab44eb0e") + + private const val REPORT_LABEL = "password_health_report" + } +} diff --git a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/data/repository/HealthSettingsRepositoryImpl.kt b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/data/repository/HealthSettingsRepositoryImpl.kt new file mode 100644 index 000000000..a85a34641 --- /dev/null +++ b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/data/repository/HealthSettingsRepositoryImpl.kt @@ -0,0 +1,37 @@ +package de.davis.keygo.feature.password_health.data.repository + +import androidx.datastore.core.DataStore +import de.davis.keygo.feature.password_health.data.local.model.ProtoHealthSettings +import de.davis.keygo.feature.password_health.data.mapper.toDomain +import de.davis.keygo.feature.password_health.di.annotation.HealthSettingsQualifier +import de.davis.keygo.feature.password_health.domain.model.HealthSettings +import de.davis.keygo.feature.password_health.domain.repository.HealthSettingsRepository +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import org.koin.core.annotation.Single + +@Single +internal class HealthSettingsRepositoryImpl( + @HealthSettingsQualifier + private val dataStore: DataStore, +) : HealthSettingsRepository { + + override fun observeBreachCheckState(): Flow = dataStore.data + .map(ProtoHealthSettings::toDomain) + + override suspend fun getBreachCheckState(): HealthSettings = + dataStore.data.first().toDomain() + + override suspend fun setBreachEnabled(enabled: Boolean) { + dataStore.updateData { + it.toBuilder().setBreachesEnabled(enabled).build() + } + } + + override suspend fun setNotificationEnabled(enabled: Boolean) { + dataStore.updateData { + it.toBuilder().setNotificationsEnabled(enabled).build() + } + } +} diff --git a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/di/FeaturePasswordHealthModule.kt b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/di/FeaturePasswordHealthModule.kt new file mode 100644 index 000000000..d705ac455 --- /dev/null +++ b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/di/FeaturePasswordHealthModule.kt @@ -0,0 +1,87 @@ +package de.davis.keygo.feature.password_health.di + +import android.content.Context +import androidx.datastore.dataStore +import de.davis.keygo.core.util.data.serializer.DefaultProtoSerializer +import de.davis.keygo.feature.password_health.data.local.model.ProtoHealthNotificationState +import de.davis.keygo.feature.password_health.data.local.model.ProtoHealthReportStore +import de.davis.keygo.feature.password_health.data.local.model.ProtoHealthSettings +import de.davis.keygo.feature.password_health.di.annotation.BreachedQualifier +import de.davis.keygo.feature.password_health.di.annotation.HealthNotificationStateQualifier +import de.davis.keygo.feature.password_health.di.annotation.HealthReportStore +import de.davis.keygo.feature.password_health.di.annotation.HealthSettingsQualifier +import okhttp3.Dispatcher +import okhttp3.OkHttpClient +import org.koin.core.annotation.ComponentScan +import org.koin.core.annotation.Configuration +import org.koin.core.annotation.Module +import org.koin.core.annotation.Single +import kotlin.time.Duration.Companion.seconds + +@Module +@Configuration +@ComponentScan("de.davis.keygo.feature.password_health") +object FeaturePasswordHealthModule { + + private val LOOKUP_TIMEOUT = 3.seconds + private const val MAX_PARALLEL_LOOKUPS = 8 + + @Single + @BreachedQualifier + internal fun provideBreachedHttpClient(applicationContext: Context): OkHttpClient { + val userAgent = "KeyGo/${applicationContext.versionName()}" + + return OkHttpClient.Builder() + .cache(null) + .callTimeout(LOOKUP_TIMEOUT) + .dispatcher(Dispatcher().apply { maxRequestsPerHost = MAX_PARALLEL_LOOKUPS }) + .addInterceptor { chain -> + chain.proceed( + chain.request().newBuilder().header("User-Agent", userAgent).build(), + ) + } + .build() + } + + private val Context.healthSettingsDataStore by dataStore( + "health_settings.pb", + DefaultProtoSerializer( + defaultInstance = ProtoHealthSettings.getDefaultInstance(), + parser = ProtoHealthSettings.parser() + ) + ) + + private val Context.healthReportStoreDataStore by dataStore( + "password_health_report_store.pb", + DefaultProtoSerializer( + defaultInstance = ProtoHealthReportStore.getDefaultInstance(), + parser = ProtoHealthReportStore.parser() + ) + ) + + private val Context.healthNotificationStateDataStore by dataStore( + "health_notification_state.pb", + DefaultProtoSerializer( + defaultInstance = ProtoHealthNotificationState.getDefaultInstance(), + parser = ProtoHealthNotificationState.parser() + ) + ) + + @Single + @HealthSettingsQualifier + internal fun provideHealthSettingsDataStore(context: Context) = + context.healthSettingsDataStore + + @Single + @HealthReportStore + internal fun provideHealthReportStoreDataStore(context: Context) = + context.healthReportStoreDataStore + + @Single + @HealthNotificationStateQualifier + internal fun provideHealthNotificationStateDataStore(context: Context) = + context.healthNotificationStateDataStore + + private fun Context.versionName(): String = + packageManager.getPackageInfo(packageName, 0).versionName.orEmpty() +} diff --git a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/di/annotation/BreachedQualifier.kt b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/di/annotation/BreachedQualifier.kt new file mode 100644 index 000000000..3fe4a4b72 --- /dev/null +++ b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/di/annotation/BreachedQualifier.kt @@ -0,0 +1,6 @@ +package de.davis.keygo.feature.password_health.di.annotation + +import org.koin.core.annotation.Named + +@Named +internal annotation class BreachedQualifier diff --git a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/di/annotation/HealthNotificationStateQualifier.kt b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/di/annotation/HealthNotificationStateQualifier.kt new file mode 100644 index 000000000..275325677 --- /dev/null +++ b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/di/annotation/HealthNotificationStateQualifier.kt @@ -0,0 +1,6 @@ +package de.davis.keygo.feature.password_health.di.annotation + +import org.koin.core.annotation.Named + +@Named +internal annotation class HealthNotificationStateQualifier diff --git a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/di/annotation/HealthReportStore.kt b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/di/annotation/HealthReportStore.kt new file mode 100644 index 000000000..0ed3dc52e --- /dev/null +++ b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/di/annotation/HealthReportStore.kt @@ -0,0 +1,6 @@ +package de.davis.keygo.feature.password_health.di.annotation + +import org.koin.core.annotation.Named + +@Named +internal annotation class HealthReportStore diff --git a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/di/annotation/HealthSettingsQualifier.kt b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/di/annotation/HealthSettingsQualifier.kt new file mode 100644 index 000000000..babb315ed --- /dev/null +++ b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/di/annotation/HealthSettingsQualifier.kt @@ -0,0 +1,6 @@ +package de.davis.keygo.feature.password_health.di.annotation + +import org.koin.core.annotation.Named + +@Named +internal annotation class HealthSettingsQualifier diff --git a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/HealthCheckNotifierScheduler.kt b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/HealthCheckNotifierScheduler.kt new file mode 100644 index 000000000..40b649f2c --- /dev/null +++ b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/HealthCheckNotifierScheduler.kt @@ -0,0 +1,7 @@ +package de.davis.keygo.feature.password_health.domain + +interface HealthCheckNotifierScheduler { + + fun scheduleHealthReminder() + fun cancel() +} diff --git a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/LoginFingerprinter.kt b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/LoginFingerprinter.kt new file mode 100644 index 000000000..413a464fe --- /dev/null +++ b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/LoginFingerprinter.kt @@ -0,0 +1,14 @@ +package de.davis.keygo.feature.password_health.domain + +import de.davis.keygo.core.item.domain.model.Login +import de.davis.keygo.feature.password_health.domain.model.HealthFingerprint +import kotlinx.coroutines.Dispatchers +import kotlin.coroutines.CoroutineContext + +interface LoginFingerprinter { + + suspend fun fingerprint( + login: Login, + coroutineContext: CoroutineContext = Dispatchers.Default + ): HealthFingerprint? +} diff --git a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/Notifier.kt b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/Notifier.kt new file mode 100644 index 000000000..117d5e524 --- /dev/null +++ b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/Notifier.kt @@ -0,0 +1,10 @@ +package de.davis.keygo.feature.password_health.domain + +import de.davis.keygo.feature.password_health.domain.model.KeyGoNotification + +interface Notifier { + + fun canNotify(): Boolean + fun sendNotification(notification: KeyGoNotification) + fun cancel(kind: KeyGoNotification.Kind) +} diff --git a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/PasswordHealthAttention.kt b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/PasswordHealthAttention.kt new file mode 100644 index 000000000..34d710254 --- /dev/null +++ b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/PasswordHealthAttention.kt @@ -0,0 +1,33 @@ +package de.davis.keygo.feature.password_health.domain + +import de.davis.keygo.core.security.domain.Session +import de.davis.keygo.core.util.di.annotation.AppScopeQualifier +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update +import org.koin.core.annotation.Single + +@Single +class PasswordHealthAttention( + session: Session, + @AppScopeQualifier appScope: CoroutineScope, +) { + + private val count = MutableStateFlow(0) + + // A scan that finishes after locking can still publish, so the lock state gates the value + // rather than a clear() that might run first. + val needsAttention: StateFlow = + combine(count, session.isActive) { count, active -> if (active) count else 0 } + .stateIn(appScope, SharingStarted.Eagerly, 0) + + internal fun update(count: Int) { + this.count.update { count } + } + + internal fun clear() = update(0) +} diff --git a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/UnlockHealthScan.kt b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/UnlockHealthScan.kt new file mode 100644 index 000000000..764fa4eeb --- /dev/null +++ b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/UnlockHealthScan.kt @@ -0,0 +1,27 @@ +package de.davis.keygo.feature.password_health.domain + +import de.davis.keygo.core.security.domain.Session +import de.davis.keygo.core.util.di.annotation.AppScopeQualifier +import de.davis.keygo.feature.password_health.domain.usecase.PasswordHealthReportUseCase +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch +import org.koin.core.annotation.Single + +@Single(createdAtStart = true) +internal class UnlockHealthScan( + session: Session, + passwordHealth: PasswordHealthReportUseCase, + attention: PasswordHealthAttention, + @AppScopeQualifier appScope: CoroutineScope, +) { + + init { + appScope.launch { + session.isActive.collectLatest { active -> + if (active) passwordHealth(force = false) + else attention.clear() + } + } + } +} diff --git a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/checker/BreachedPasswordCheck.kt b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/checker/BreachedPasswordCheck.kt new file mode 100644 index 000000000..e25cb3b08 --- /dev/null +++ b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/checker/BreachedPasswordCheck.kt @@ -0,0 +1,131 @@ +package de.davis.keygo.feature.password_health.domain.checker + +import android.util.Log +import de.davis.keygo.core.item.domain.alias.ItemId +import de.davis.keygo.core.util.Result +import de.davis.keygo.feature.password_health.domain.model.BreachedError +import de.davis.keygo.feature.password_health.domain.model.CheckGap +import de.davis.keygo.feature.password_health.domain.model.CheckKind +import de.davis.keygo.feature.password_health.domain.model.CheckOutcome +import de.davis.keygo.feature.password_health.domain.model.GapReason +import de.davis.keygo.feature.password_health.domain.model.HealthFinding +import de.davis.keygo.feature.password_health.domain.model.ItemIssue +import de.davis.keygo.feature.password_health.domain.model.PasswordCandidate +import de.davis.keygo.feature.password_health.domain.repository.BreachedRepository +import de.davis.keygo.feature.password_health.domain.repository.BreachedRepository.Companion.PREFIX_LENGTH +import de.davis.keygo.feature.password_health.domain.repository.ConnectivityRepository +import de.davis.keygo.feature.password_health.domain.repository.HealthSettingsRepository +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.withContext +import org.koin.core.annotation.Single +import java.nio.CharBuffer +import java.security.MessageDigest + +@Single +internal class BreachedPasswordCheck( + private val breachedRepository: BreachedRepository, + private val healthSettingsRepository: HealthSettingsRepository, + private val connectivityRepository: ConnectivityRepository, +) : PasswordHealthChecker { + + override val type = CheckKind.Breach + + override suspend fun check(candidates: List): CheckOutcome { + if (candidates.isEmpty()) return CheckOutcome() + + if (!healthSettingsRepository.getBreachCheckState().breachesEnabled) + return CheckOutcome.skipped(GapReason.Disabled, candidates) + + if (!connectivityRepository.observeInternet().first()) + return CheckOutcome.skipped(GapReason.Unreachable, candidates) + + val ranges = candidates.byRange() + + val answers = coroutineScope { + ranges.map { (prefix, bySuffix) -> + async { bySuffix to breachedRepository.occurrences(prefix, bySuffix.keys) } + }.awaitAll() + } + + val findings = mutableListOf() + val unchecked = mutableSetOf() + val reasons = mutableSetOf() + + answers.forEach { (bySuffix, answer) -> + when (answer) { + is Result.Success -> findings += answer.success.findings(bySuffix) + + is Result.Failure -> { + bySuffix.values.forEach { sharing -> sharing.mapTo(unchecked) { it.id } } + reasons += answer.error.asGapReason() + } + } + } + + val gap = when { + unchecked.isEmpty() -> null + + // A connection that is down explains the run better than one range answering oddly. + GapReason.Unreachable in reasons -> + CheckGap(reason = GapReason.Unreachable, unchecked = unchecked) + + else -> CheckGap(reason = GapReason.Failed, unchecked = unchecked) + } + + if (gap != null) Log.w( + TAG, + "${unchecked.size} of ${candidates.size} passwords went unchecked: ${gap.reason}", + ) + + return CheckOutcome(findings = findings, gap = gap) + } + + private fun Map.findings( + bySuffix: Map>, + ): List = flatMap { (suffix, occurrences) -> + bySuffix[suffix].orEmpty().map { + HealthFinding.Item( + id = it.id, + issue = ItemIssue.Breached(occurrences = occurrences), + ) + } + } + + private fun BreachedError.asGapReason() = when (this) { + BreachedError.Unreachable -> GapReason.Unreachable + BreachedError.ApiFailed, BreachedError.InvalidPrefix -> GapReason.Failed + } + + private suspend fun List.byRange(): Map>> { + val sha1 = MessageDigest.getInstance("SHA-1") + val ranges = mutableMapOf>>() + + forEach { candidate -> + val hash = sha1.hash(candidate.password) + ranges.getOrPut(hash.take(PREFIX_LENGTH)) { mutableMapOf() } + .getOrPut(hash.substring(PREFIX_LENGTH)) { mutableListOf() } + .add(candidate) + } + + return ranges + } + + private suspend fun MessageDigest.hash(password: CharArray): String = + withContext(Dispatchers.Default) { + val encoded = Charsets.UTF_8.encode(CharBuffer.wrap(password)) + try { + update(encoded) + return@withContext digest().toHexString(format = HexFormat.UpperCase) + } finally { + if (encoded.hasArray()) encoded.array().fill(0) + } + } + + companion object { + private const val TAG = "BreachedPasswordCheck" + } +} diff --git a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/checker/PasswordHealthChecker.kt b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/checker/PasswordHealthChecker.kt new file mode 100644 index 000000000..ffe8a57a6 --- /dev/null +++ b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/checker/PasswordHealthChecker.kt @@ -0,0 +1,11 @@ +package de.davis.keygo.feature.password_health.domain.checker + +import de.davis.keygo.feature.password_health.domain.model.CheckKind +import de.davis.keygo.feature.password_health.domain.model.CheckOutcome +import de.davis.keygo.feature.password_health.domain.model.PasswordCandidate + +interface PasswordHealthChecker { + val type: CheckKind + + suspend fun check(candidates: List): CheckOutcome +} diff --git a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/checker/PasswordSimilarity.kt b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/checker/PasswordSimilarity.kt new file mode 100644 index 000000000..092adb6e5 --- /dev/null +++ b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/checker/PasswordSimilarity.kt @@ -0,0 +1,394 @@ +package de.davis.keygo.feature.password_health.domain.checker + +import de.davis.keygo.feature.password_health.domain.checker.PasswordShape.Companion.FREE +import de.davis.keygo.feature.password_health.domain.checker.PasswordShape.Companion.SECRET +import de.davis.keygo.feature.password_health.domain.checker.PasswordShape.Companion.SEPARATOR +import de.davis.keygo.feature.password_health.domain.checker.PasswordShape.Companion.WORD +import kotlin.math.abs + +private val LEET_CLASSES = mapOf( + 'i' to setOf('l', '1', '|', '!'), + 'o' to setOf('0'), + 'a' to setOf('@', '4'), + 'e' to setOf('3'), + 's' to setOf('5', '$'), + 't' to setOf('7', '+'), + 'b' to setOf('8'), + 'g' to setOf('9'), +) + +private val LEET_FOLD = LEET_CLASSES.flatMap { (key, values) -> values.map { it to key } }.toMap() + +internal fun CharArray.canonicalize(): CharArray = CharArray(size) { index -> + val lower = this[index].lowercaseChar() + LEET_FOLD[lower] ?: lower +} + +private const val WORD_SEPARATORS = "-_. " + +/** Letters in a row that read as a word even when no list has it: a site, a surname. */ +private const val WORD_RUN = 4 + +/** Digits in a row short enough to be a counter rather than a secret. */ +private const val SHORT_DIGIT_RUN = 3 + +private const val YEAR_LENGTH = 4 + +/** + * What each character of one password costs an attacker, worked out once. + * + * Every pair in the vault asks the same questions about the same passwords, and each + * answer depends on one password alone. Classifying up front turns every later question + * into an array subtraction, which is what makes a dictionary lookup affordable inside a + * sweep that is quadratic in the size of the vault. + * + * Characters fall into four kinds: + * + * - [WORD]: a run of letters [WordModel] reads as a word, in any spelling, or a run long + * enough that its shape alone says so. Free. + * - [FREE]: a four digit year, or a run of three digits or fewer. A year falls in a few + * dozen tries and a short counter is habit, so neither is evidence. + * - [SEPARATOR]: what holds a passphrase together. Free inside a run of words, but not + * on its own. + * - [SECRET]: everything else, which is what an attacker actually has to crack. + * + * The whole password is classified, never a fragment of it, so a word cut in half by a + * shared prefix is still recognised as the word it is part of. + */ +internal class PasswordShape(private val raw: CharArray) { + + val size = raw.size + + val canonical: CharArray = raw.canonicalize() + + private val kind = ByteArray(size) + + /** Where the run holding each character began, so "one slot moved" is answerable. */ + private val slotStart = IntArray(size) + + /** Word runs in order, as parallel start/end arrays. Disjoint by construction. */ + private val wordStarts: IntArray + private val wordEnds: IntArray + + /** Running counts, so every range question below is two array reads. */ + private val secretBefore = IntArray(size + 1) + private val crackableBefore = IntArray(size + 1) + private val unwordlikeBefore = IntArray(size + 1) + + init { + val starts = ArrayList() + val ends = ArrayList() + classify(starts, ends) + wordStarts = starts.toIntArray() + wordEnds = ends.toIntArray() + + for (index in 0 until size) { + secretBefore[index + 1] = secretBefore[index] + if (isSecret(index)) 1 else 0 + crackableBefore[index + 1] = + crackableBefore[index] + if (kind[index] == SECRET) 1 else 0 + unwordlikeBefore[index + 1] = + unwordlikeBefore[index] + if (isWordlike(index)) 0 else 1 + } + } + + /** + * Length of `[from, until)` once the parts an attacker gets for free are discounted. + * + * A separator counts, because on its own it is a character like any other. A run of + * them between words does not, which is what [readsAsWords] is for. + */ + fun secretLength(from: Int, until: Int): Int = + if (from >= until) 0 else countBetween(secretBefore, from, until) + + /** + * Whether `[from, until)` is nothing but words and the separators between them. Such + * an end counts for nothing at all, however long it is: two passwords that share only + * "correct-horse-" share only what a wordlist hands out. + */ + fun readsAsWords(from: Int, until: Int): Boolean = + from < until && countBetween(unwordlikeBefore, from, until) == 0 + + /** + * Whether `[from, until)` holds nothing an attacker would have to crack. + * + * Unlike [secretLength] a separator does not count here. The question this answers is + * what the difference between two passwords is made of, and a passphrase written with + * dots instead of hyphens has not changed its secret. + */ + fun holdsNothingToCrack(from: Int, until: Int): Boolean = + from >= minOf(until, size) || countBetween(crackableBefore, from, until) == 0 + + /** Whole words inside `[from, until)`. A word cut by the range does not count. */ + fun wordsWithin(from: Int, until: Int): Int { + var count = 0 + for (index in wordStarts.indices) + if (wordStarts[index] >= from && wordEnds[index] <= until) count++ + return count + } + + /** + * The number `[from, until)` spells, or -1 when it is not digits the whole way or is + * longer than a Long holds. Leading zeros read as the number they spell, so "06" sits + * five away from "11". + */ + fun numberWithin(from: Int, until: Int): Long { + val start = from.coerceAtLeast(0) + val end = minOf(until, size) + if (start >= end || end - start > MAX_DIGITS) return -1 + + var value = 0L + for (index in start until end) { + if (!raw[index].isDigit()) return -1 + value = value * 10 + (raw[index] - '0') + } + return value + } + + /** + * The whole number the digits in `[from, until)` belong to, or -1 when the range holds + * no digits or holds parts of two different numbers. + * + * Reaching outside the range is the point. What separates two passwords is rarely a + * whole number: "Summer2023!" and "Summer2024!" disagree about one character of one, + * and the question worth asking is what 2023 and 2024 are to each other, not what 3 + * and 4 are. + */ + fun numberAround(from: Int, until: Int): Long { + val start = from.coerceAtLeast(0) + val end = minOf(until, size) + var at = start + while (at < end && !raw[at].isDigit()) at++ + if (at >= end) return -1 + + var runStart = at + while (runStart > 0 && raw[runStart - 1].isDigit()) runStart-- + var runEnd = at + while (runEnd < size && raw[runEnd].isDigit()) runEnd++ + + // A range straddling two numbers says nothing about either of them. + for (index in runEnd until end) if (raw[index].isDigit()) return -1 + + return numberWithin(runStart, runEnd) + } + + /** + * Whether a run begins at [index], so a boundary drawn here does not cut through the + * middle of a word or a number. The ends of the password count as boundaries. + */ + fun startsRun(index: Int): Boolean = + index <= 0 || index >= size || slotStart[index] == index + + /** + * Whether `[from, until)` stays inside one run, and that run is not a word. + * + * Bumping a year, a counter or a symbol moves a single slot and leaves everything else + * where it was. Two slots moving at once is two independent choices, which is what + * unrelated passwords off the same template look like: "Vanilla2000+" and + * "Vanilla2007#" agree on the word and on nothing else. + * + * A word never counts, however alone it stands. Trading one word for another is + * trading the whole of what an attacker would have had to guess, so "garden22" and + * "harden22" are not one password with an edit in it. + */ + fun movesOneSlot(from: Int, until: Int): Boolean { + val end = minOf(until, size) + if (from >= end) return true + if (kind[from] == WORD) return false + + val slot = slotStart[from] + for (index in from until end) if (slotStart[index] != slot) return false + return true + } + + fun wipe() = canonical.fill('') + + private fun countBetween(running: IntArray, from: Int, until: Int) = + running[until.coerceAtMost(size)] - running[from.coerceAtLeast(0)] + + private fun isSecret(index: Int) = kind[index] == SECRET || kind[index] == SEPARATOR + + private fun isWordlike(index: Int) = kind[index] == WORD || kind[index] == SEPARATOR + + private fun classify(starts: ArrayList, ends: ArrayList) { + var index = 0 + while (index < size) { + if (index + YEAR_LENGTH <= size && isYearAt(index)) { + mark(FREE, index, index + YEAR_LENGTH) + index += YEAR_LENGTH + continue + } + + val word = wordAt(index) + if (word > 0) { + mark(WORD, index, index + word) + starts += index + ends += index + word + index += word + continue + } + + val digits = digitRunAt(index) + if (digits > 0) { + // Past the whole run either way, so a long one cannot be discounted a few + // digits at a time from its tail. + val free = digits <= SHORT_DIGIT_RUN || countsOrRepeats(index, index + digits) + mark(if (free) FREE else SECRET, index, index + digits) + index += digits + continue + } + + // A symbol stands alone: two of them side by side are two choices, not one. + mark(if (raw[index] in WORD_SEPARATORS) SEPARATOR else SECRET, index, index + 1) + index++ + } + } + + private fun mark(value: Byte, from: Int, until: Int) { + kind.fill(value, from, until) + for (index in from until until) slotStart[index] = from + } + + /** + * Length of the word starting at [index]: shape first, the model only for what shape + * cannot read. + * + * Shape comes first because it knows a capital starts something. The model scores + * whatever run of letters it is handed, and in "SummerTime" that run is ten letters + * and one word where a person wrote two. Losing the boundary loses the count, and two + * shared words is evidence a pair is one password edited where one shared word is not. + * + * The model's job is the spellings shape was never able to read: a word that is + * shouted, leeted, or capitalised in the middle. Shape keeps the rest, because plenty + * of what a person writes into a password is in no dictionary and is still no secret, + * like a site name or a surname, and those are written the way words are. + */ + private fun wordAt(index: Int): Int { + val start = if (raw[index].isUpperCase()) index + 1 else index + var end = start + while (end < size && raw[end].isLowerCase()) end++ + val run = if (end == start) 0 else end - index + if (run >= WORD_RUN) return run + + return WordModel.wordRunAt(canonical, raw, index, size) + } + + /** + * Whether `[from, until)` counts up, counts down, or repeats one digit. + * + * "12345678" is as long as a number gets and costs an attacker nothing, and so does + * "87654321" and "000000". Treating a run that long as a secret purely because of its + * length reported two weak passwords as a pair whenever they happened to start + * counting from the same place, which was a seventh of every false pair left. + */ + private fun countsOrRepeats(from: Int, until: Int): Boolean { + val step = raw[from + 1] - raw[from] + if (step != 0 && step != 1 && step != -1) return false + + for (index in from + 1 until until) if (raw[index] - raw[index - 1] != step) return false + return true + } + + private fun digitRunAt(index: Int): Int { + var end = index + while (end < size && raw[end].isDigit()) end++ + return end - index + } + + private fun isYearAt(index: Int): Boolean { + val leads = raw[index] == '1' && raw[index + 1] == '9' || + raw[index] == '2' && raw[index + 1] == '0' + return leads && raw[index + 2].isDigit() && raw[index + 3].isDigit() + } + + private companion object { + const val SECRET: Byte = 0 + const val FREE: Byte = 1 + const val WORD: Byte = 2 + const val SEPARATOR: Byte = 3 + + /** Digits a Long still holds. Past this the value is not worth reading. */ + const val MAX_DIGITS = 18 + } +} + +internal fun commonPrefixLength(first: CharArray, second: CharArray): Int { + val limit = minOf(first.size, second.size) + var length = 0 + while (length < limit && first[length] == second[length]) length++ + return length +} + +internal fun commonSuffixLength(first: CharArray, second: CharArray): Int { + val limit = minOf(first.size, second.size) + var length = 0 + while (length < limit && first[first.size - 1 - length] == second[second.size - 1 - length]) + length++ + return length +} + +/** + * Optimal string alignment distance, which counts insertions, deletions, substitutions + * and transpositions of adjacent characters. + * + * Gives up as soon as a whole row exceeds [limit] and returns `limit + 1`. Most pairs in + * a vault are nowhere near each other, so that early exit is what keeps the pairwise + * sweep affordable. + * + * [rows] is three scratch rows, each at least `second.size + 1` long. They are handed in + * rather than allocated because the sweep runs this once per pair, and three arrays per + * pair is the bulk of what the check churns through. + */ +internal fun boundedDistance( + first: CharArray, + second: CharArray, + limit: Int, + rows: DistanceRows, +): Int { + if (abs(first.size - second.size) > limit) return limit + 1 + + var twoRowsAgo = rows.first + var previousRow = rows.second + var currentRow = rows.third + for (j in 0..second.size) previousRow[j] = j + + for (i in 1..first.size) { + currentRow[0] = i + var bestInRow = i + + for (j in 1..second.size) { + val substitution = if (first[i - 1] == second[j - 1]) 0 else 1 + var cost = minOf( + previousRow[j] + 1, + currentRow[j - 1] + 1, + previousRow[j - 1] + substitution, + ) + if (i > 1 && j > 1 && first[i - 1] == second[j - 2] && first[i - 2] == second[j - 1]) + cost = minOf(cost, twoRowsAgo[j - 2] + 1) + + currentRow[j] = cost + if (cost < bestInRow) bestInRow = cost + } + + if (bestInRow > limit) return limit + 1 + + val recycled = twoRowsAgo + twoRowsAgo = previousRow + previousRow = currentRow + currentRow = recycled + } + + return previousRow[second.size] +} + +/** Scratch space for [boundedDistance], reused across every pair in one sweep. */ +internal class DistanceRows(width: Int) { + val first = IntArray(width + 1) + val second = IntArray(width + 1) + val third = IntArray(width + 1) +} + +/** Value-equality wrapper so a char array can key a map. */ +internal class CharArrayKey(private val value: CharArray) { + override fun equals(other: Any?) = other is CharArrayKey && value.contentEquals(other.value) + override fun hashCode() = value.contentHashCode() +} diff --git a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/checker/ReusePasswordCheck.kt b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/checker/ReusePasswordCheck.kt new file mode 100644 index 000000000..7225a7966 --- /dev/null +++ b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/checker/ReusePasswordCheck.kt @@ -0,0 +1,31 @@ +package de.davis.keygo.feature.password_health.domain.checker + +import de.davis.keygo.feature.password_health.domain.model.CheckKind +import de.davis.keygo.feature.password_health.domain.model.CheckOutcome +import de.davis.keygo.feature.password_health.domain.model.HealthFinding +import de.davis.keygo.feature.password_health.domain.model.PasswordCandidate +import de.davis.keygo.feature.password_health.domain.model.RelationType +import org.koin.core.annotation.Single + +@Single +internal class ReusePasswordCheck : PasswordHealthChecker { + + override val type = CheckKind.Reuse + + override suspend fun check(candidates: List): CheckOutcome = CheckOutcome( + findings = candidates.groupBy { PasswordKey(it.password) } + .values + .filter { it.size > 1 } + .map { sharing -> + HealthFinding.Relation( + relatedItemIds = sharing.mapTo(mutableSetOf()) { it.id }, + type = RelationType.Reused, + ) + }, + ) +} + +private class PasswordKey(private val value: CharArray) { + override fun equals(other: Any?) = other is PasswordKey && value.contentEquals(other.value) + override fun hashCode() = value.contentHashCode() +} diff --git a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/checker/SimilarPasswordChecker.kt b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/checker/SimilarPasswordChecker.kt new file mode 100644 index 000000000..a51d5e64a --- /dev/null +++ b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/checker/SimilarPasswordChecker.kt @@ -0,0 +1,310 @@ +package de.davis.keygo.feature.password_health.domain.checker + +import de.davis.keygo.core.item.domain.alias.ItemId +import de.davis.keygo.feature.password_health.domain.checker.SimilarPasswordChecker.Companion.AFFIX_SHARE +import de.davis.keygo.feature.password_health.domain.model.CheckKind +import de.davis.keygo.feature.password_health.domain.model.CheckOutcome +import de.davis.keygo.feature.password_health.domain.model.HealthFinding +import de.davis.keygo.feature.password_health.domain.model.PasswordCandidate +import de.davis.keygo.feature.password_health.domain.model.RelationType +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.withContext +import org.koin.core.annotation.Single +import kotlin.math.abs + +/** + * Flags passwords that are variations of one another rather than outright copies: + * leetspeak rewrites ("P@ssw0rd!" against "password!") and templates where only one part + * moved ("Summer2023!" against "Summer2024!", "facebook_Kd8!" against "twitter_Kd8!"). + * + * Exact copies are left to [ReusePasswordCheck] on purpose. Reporting them here too would + * hang a second edge on every reused pair and count the same logins again in the summary + * line. Copies still appear inside a wider family, which is not the same thing: two + * copies of a password sitting beside a rewrite of it are each a variation of that + * rewrite, so all of them belong in the one relation. + */ +@Single +internal class SimilarPasswordChecker : PasswordHealthChecker { + + override val type = CheckKind.Similarity + + override suspend fun check(candidates: List): CheckOutcome = + withContext(Dispatchers.Default) { + // A shape holds a canonical form of the password, lowercased and leet-folded, + // which still leaves it readable. It gets the same wipe as the original. + val shapes = candidates.map { it to PasswordShape(it.password) } + + val relations = try { + val comparable = shapes.filter { (_, shape) -> shape.size >= MIN_LENGTH } + + val edges = mutableSetOf>() + collectLeetFamilies(comparable, into = edges) + collectVariantPairs(comparable, into = edges) + + edges.map { + HealthFinding.Relation(relatedItemIds = it, type = RelationType.Similar) + } + } finally { + shapes.forEach { (_, shape) -> shape.wipe() } + } + + CheckOutcome(findings = relations) + } + + private fun collectLeetFamilies( + comparable: List>, + into: MutableSet>, + ) { + comparable.groupBy { (_, shape) -> CharArrayKey(shape.canonical) } + .values + .filter { family -> family.distinctBy { CharArrayKey(it.first.password) }.size > 1 } + .mapTo(into) { family -> family.mapTo(mutableSetOf()) { (candidate, _) -> candidate.id } } + } + + private suspend fun collectVariantPairs( + comparable: List>, + into: MutableSet>, + ) { + val rows = DistanceRows(comparable.maxOfOrNull { (_, shape) -> shape.size } ?: return) + + for (i in comparable.indices) { + currentCoroutineContext().ensureActive() + val (first, firstShape) = comparable[i] + + for (j in i + 1..comparable.lastIndex) { + val (second, secondShape) = comparable[j] + + if (first.password.contentEquals(second.password)) continue + if (firstShape.canonical.contentEquals(secondShape.canonical)) continue + if (isVariant(firstShape, secondShape, rows)) into += setOf(first.id, second.id) + } + } + } + + private fun isVariant( + first: PasswordShape, + second: PasswordShape, + rows: DistanceRows, + ): Boolean { + val prefix = commonPrefixLength(first.canonical, second.canonical) + val suffix = commonSuffixLength(first.canonical, second.canonical) + val shared = weighPrefix(first, second, prefix) + weighSuffix(first, second, suffix) + + if (extendsTheOther(first, second, prefix, suffix)) return true + if (sharesTemplate(first, second, shared)) return true + if (!carriesEvidence(first, second, prefix, suffix, shared)) return false + + val allowed = (minOf(first.size, second.size) / MAX_DISTANCE_RATIO) + .coerceIn(1, MAX_DISTANCE) + return boundedDistance(first.canonical, second.canonical, allowed, rows) <= allowed + } + + /** + * Whether one password is the whole of the other with something added at one end. + * + * The edit budget cannot answer this: it is proportional to length and an appended + * site token is not, so past three or four characters the budget rules out a pair one + * of which plainly contains the other. + * + * Containment is evidence even when the shared part is guessable from end to end. + * Nothing in "Summer2023" has to be cracked, but an attacker holding + * "Summer2023!ForWork" tries it anyway, and that is the question being asked. + * + * What it must not do is read a stem as a password. The added part has to begin where + * a run begins, or "carpenter" pulls in "carpenters" and every passphrase built on a + * shared stem drags its neighbours in with it. + */ + private fun extendsTheOther( + first: PasswordShape, + second: PasswordShape, + prefix: Int, + suffix: Int, + ): Boolean { + val shorter = minOf(first.size, second.size) + if (shorter < MIN_CONTAINED_LENGTH) return false + if (first.size == second.size) return false + + val longer = if (first.size > second.size) first else second + if (prefix >= shorter) return longer.startsRun(shorter) + if (suffix >= shorter) return longer.startsRun(longer.size - shorter) + return false + } + + /** + * Whether the two were built from the same template with one part moved. + * + * Only shared material an attacker would have to crack counts. A year, a dictionary + * word and a site name are guessed rather than cracked, so [PasswordShape.secretLength] + * takes them out of the shared ends, and an end that reads as words the whole way + * along counts for nothing at all. What is left has to cover [AFFIX_SHARE] of the + * secret material in the longer password, so a short password cannot pull a long one + * in behind a prefix that merely happens to be most of it. + */ + private fun sharesTemplate(first: PasswordShape, second: PasswordShape, shared: Int): Boolean { + if (minOf(first.size, second.size) < MIN_TEMPLATE_LENGTH) return false + if (shared < MIN_AFFIX_LENGTH) return false + + val longer = if (first.size >= second.size) first else second + return shared >= AFFIX_SHARE * longer.secretLength(0, longer.size) + } + + /** + * Whether a pair the edit budget would accept is worth reporting at all. + * + * Being a few edits apart is not the same as being the same password. Two short + * passwords built the same way land near each other by construction, which is why + * almost every false pair used to come from the edit rule. + * + * Sharing material an attacker has to crack is evidence on its own ("b7Kq-vn2LpXw" + * with one letter of difference). Failing that, the shared ends have to hold at least + * one whole word, and then the difference has to be the kind a person makes to their + * own password rather than the kind that tells two passwords apart: + * + * - nothing to crack changed and no whole word went with it, so the edit landed + * inside a word, a year or the punctuation ("harbor_meadow_84" against + * "harbr_meadow_84"), or + * - exactly one slot moved ("Summer2023!" against "Summer2024!"). + * + * Sharing two whole words is evidence on its own. Letting it buy only a smaller edit + * budget instead bought under a point of precision and cost more than two of recall; + * what actually separates "P3n9uinLaurel2024@" from "P3n9uinLaurel2003+" is + * [numbersMovedByAStep]. + * + * What this turns away is the pair with none of that: "garden22" beside "harden22", + * where the edit broke the only word there was; "facebook_Kd8!" beside + * "facebook_Xq2!", where the shared part is a site name and each side keeps a token + * the other says nothing about; and "Vanilla2000+" beside "Vanilla2007#", where two + * slots moved at once, which is two choices rather than one edit. + */ + private fun carriesEvidence( + first: PasswordShape, + second: PasswordShape, + prefix: Int, + suffix: Int, + shared: Int, + ): Boolean { + val firstEnd = maxOf(prefix, first.size - suffix) + val secondEnd = maxOf(prefix, second.size - suffix) + if (!numbersMovedByAStep(first, second, prefix, firstEnd, secondEnd)) return false + + if (shared >= MIN_AFFIX_LENGTH) return true + + val words = minOf(sharedWords(first, prefix, suffix), sharedWords(second, prefix, suffix)) + if (words >= SHARED_WORDS_AS_EVIDENCE) return true + if (words < MIN_SHARED_WORDS) return false + + return first.holdsNothingToCrack(prefix, firstEnd) && + second.holdsNothingToCrack(prefix, secondEnd) || + first.movesOneSlot(prefix, firstEnd) && second.movesOneSlot(prefix, secondEnd) + } + + /** + * Whether the numbers the two disagree about moved by a step a person makes. + * + * A counter someone bumped goes up by one, and a year rolls on by one or two. Two + * numbers that merely occupy the same slot land wherever they land, so eighty-two + * apart is not a bump, it is two people picking a number. Nothing else separates + * "Phoenix*10" beside "Phoenix*92" from "Summer2023!" beside "Summer2024!". + * + * The numbers compared are the whole ones the difference falls inside, not the part of + * them that differs, so a year and the symbol after it moving together is still read + * as 2024 against 2003. Anything that is not one number on both sides is left to the + * rules that follow. + */ + private fun numbersMovedByAStep( + first: PasswordShape, + second: PasswordShape, + prefix: Int, + firstEnd: Int, + secondEnd: Int, + ): Boolean { + val before = first.numberAround(prefix, firstEnd) + val after = second.numberAround(prefix, secondEnd) + if (before < 0 || after < 0) return true + + return abs(before - after) <= MAX_COUNTER_STEP + } + + /** Whole words inside the shared ends. The two ends can overlap on a short password. */ + private fun sharedWords(shape: PasswordShape, prefix: Int, suffix: Int): Int { + val tail = maxOf(prefix, shape.size - suffix) + return shape.wordsWithin(0, prefix) + shape.wordsWithin(tail, shape.size) + } + + private fun weighPrefix(first: PasswordShape, second: PasswordShape, prefix: Int): Int { + if (first.readsAsWords(0, prefix) && second.readsAsWords(0, prefix)) return 0 + + return minOf(first.secretLength(0, prefix), second.secretLength(0, prefix)) + } + + private fun weighSuffix(first: PasswordShape, second: PasswordShape, suffix: Int): Int { + if (first.readsAsWords(first.size - suffix, first.size) && + second.readsAsWords(second.size - suffix, second.size) + ) return 0 + + return minOf( + first.secretLength(first.size - suffix, first.size), + second.secretLength(second.size - suffix, second.size), + ) + } + + companion object { + /** + * Below this, similarity stops meaning anything: short passwords collide by + * accident, and the strength check already reports them. + */ + private const val MIN_LENGTH = 6 + + /** The shared-affix rule needs real length behind it to stay quiet. */ + private const val MIN_TEMPLATE_LENGTH = 8 + private const val MIN_AFFIX_LENGTH = 4 + + /** + * How much of a password has to sit inside another before containment means + * anything. Short strings turn up inside longer ones by accident. + */ + private const val MIN_CONTAINED_LENGTH = 8 + + /** + * Whole words the shared ends have to keep before an edit-distance hit is looked at + * at all. One is what separates a typo from a word swap: an edit inside the only + * word there was leaves no whole word behind it. + */ + private const val MIN_SHARED_WORDS = 1 + + /** + * The share of the longer password's secret material the shared part has to cover. + * Tuned on a labelled corpus; see SimilaritySensitivitySweep for what moving it + * costs in recall. + */ + private const val AFFIX_SHARE = 0.4 + + /** + * Shared words that are evidence on their own, whatever the difference is made of. + * Two is the point where a wordlist stops explaining the overlap: unrelated + * passphrases share one word constantly and three almost never. + */ + private const val SHARED_WORDS_AS_EVIDENCE = 2 + + /** + * How far a number may travel and still read as one someone bumped rather than two + * numbers that happen to sit in the same slot. People increment; they do not jump. + * Wide enough that a year skipping a couple of renewals survives. + */ + private const val MAX_COUNTER_STEP = 3 + + /** One edit per this many characters still counts as the same password. */ + private const val MAX_DISTANCE_RATIO = 4 + + /** + * However long the two are, never allow more edits than this. A real variation + * moves one slot: a typo, a counter, a symbol, a year. The proportional budget on + * its own gives a twenty-eight character passphrase seven edits, which is a whole + * word, and two unrelated passphrases off the same wordlist then start looking like + * one another. + */ + private const val MAX_DISTANCE = 4 + } +} diff --git a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/checker/WeakPasswordChecker.kt b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/checker/WeakPasswordChecker.kt new file mode 100644 index 000000000..b96225ced --- /dev/null +++ b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/checker/WeakPasswordChecker.kt @@ -0,0 +1,27 @@ +package de.davis.keygo.feature.password_health.domain.checker + +import de.davis.keygo.core.item.domain.model.PasswordScore +import de.davis.keygo.feature.password_health.domain.model.CheckKind +import de.davis.keygo.feature.password_health.domain.model.CheckOutcome +import de.davis.keygo.feature.password_health.domain.model.HealthFinding +import de.davis.keygo.feature.password_health.domain.model.ItemIssue +import de.davis.keygo.feature.password_health.domain.model.PasswordCandidate +import org.koin.core.annotation.Single + +@Single +internal class WeakPasswordChecker : PasswordHealthChecker { + override val type = CheckKind.Strength + + override suspend fun check(candidates: List): CheckOutcome = CheckOutcome( + findings = candidates.filter { it.score in WEAK_SCORES }.map { + HealthFinding.Item( + id = it.id, + issue = ItemIssue.Weak(score = it.score), + ) + }, + ) + + companion object { + private val WEAK_SCORES = setOf(PasswordScore.Ridiculous, PasswordScore.Weak) + } +} diff --git a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/checker/WordModel.kt b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/checker/WordModel.kt new file mode 100644 index 000000000..4397b3855 --- /dev/null +++ b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/checker/WordModel.kt @@ -0,0 +1,262 @@ +package de.davis.keygo.feature.password_health.domain.checker + +import de.davis.keygo.feature.password_health.domain.checker.WordModel.WORTH_IT +import me.gosimple.nbvcxz.resources.DictionaryUtil +import kotlin.math.ln + +/** + * Whether a run of letters reads like a word, which is the same question as whether an + * attacker gets it for free. + * + * A word list answers this in one language only. A German, French or Spanish word + * shouted or leeted past a shape rule is not on an English list, so the people whose + * passwords are not written in English are exactly the people the list does not help, + * and carrying a list per language does not scale. + * + * So instead: words are not random letters. "ratte" and "rttae" use the same five + * letters and only one could be a word, because languages that share an alphabet share a + * great deal of how letters follow one another. An order 3 Markov model counts, for + * every pair of letters, which letter came next, and reads a run by how much less of a + * surprise it is than the same many letters drawn at random. Trained on English alone it + * still reads "kartoffel", "papillon" and "mariposa" as words and "b7Kqxz" as a secret, + * because what it learned is mostly not English but the shape of European spelling. + * + * Training uses the nbvcxz dictionary already on the classpath, so this costs no asset + * and no download: 19,683 bytes in place of 22,160 hashes. + * + * Scoring takes a range of an already canonical array rather than a string. Building + * strings out of password fragments would leave copies behind that cannot be wiped. + */ +internal object WordModel { + + /** + * Shorter than this and a "word" is a fragment: at two characters a random token can + * be nibbled away a syllable at a time until nothing is left to crack. Three is as low + * as it can go, because passphrase wordlists are full of three letter words and + * "Oak-Elk-Ivy-Fig-Owl" has to read as five of them. + */ + const val MIN_WORD = 3 + + /** + * Length of the word starting at [from], or 0. + * + * Where the word ends has to be worked out, not assumed, because canonicalising merges + * a word with whatever follows it: "H@rb0r1993+" folds to eleven letters in a row. + * Asking whether those eleven are a word only ever answers no. + * + * So every prefix is weighed against chance. Each character earns the model's opinion + * of it less what a letter drawn at random would have cost, and the word is the prefix + * where that running total peaks: "harbor" peaks at six and every letter of "igget" + * after it takes the total back down. An average would not do this, because six + * confident characters carry five unlikely ones and the whole of "harborigget" reads + * as fine on average. + * + * A run that never gets [WORTH_IT] ahead of chance is not a word at all. + * + * The run is taken from [canonical], so a leeted spelling is one run and not three. + * [raw] only has to show the run was not digits that happened to fold into letters. + */ + fun wordRunAt(canonical: CharArray, raw: CharArray, from: Int, until: Int): Int { + var end = from + while (end < until && canonical[end].isLetter()) end++ + + val run = end - from + if (run < MIN_WORD) return 0 + + var back2 = EDGE + var back1 = EDGE + var running = 0.0 + var readable = 0 + var sawLetter = false + var best = WORTH_IT + var word = 0 + + for (index in from until end) { + // A run of digits can fold into letters by accident: "1337" canonicalises to + // "ieet". One real letter is enough to tell a respelled word from that. + if (raw[index].isLetter()) sawLetter = true + + val symbol = symbolOf(canonical[index]) + if (symbol < 0) { + // A letter the model never saw is not evidence of anything. Pick the word + // up again on the far side of it rather than score a surprise. + back2 = EDGE + back1 = EDGE + continue + } + + running += logProbability(back2, back1, symbol) - BY_CHANCE + if (!raw[index].isLetter()) running -= RESPELLING + readable++ + back2 = back1 + back1 = symbol + + val length = index - from + 1 + if (length < MIN_WORD || readable < MIN_WORD || !sawLetter) continue + + // A word can be spelled with digits in it and does not end in one. Every + // character of "1993+" folds to a letter, so "harborigget" reads as a run of + // eleven, and the only thing that still says where "harbor" stopped is that + // the characters after it were never letters to begin with. + if (!raw[index].isLetter()) continue + + // Where a word stops is as telling as how it runs. Stopping after "harbor" is + // ordinary and stopping after "harborig" is not, so the closing edge is + // charged for like any other character. + val whole = running + logProbability(back2, back1, EDGE) - BY_CHANCE + if (whole > best) { + best = whole + word = length + } else if (best - whole > GIVE_UP) { + // The word is over and the rest of the run belongs to something else. + // Stopping matters because a later stretch can read well on its own + // account and drag the word across everything in between: "qwertyuiop" is + // two readable halves and an unreadable join. The caller asks again here. + break + } + } + + if (!sawLetter) return 0 + + // Letters in an alphabet the model was never shown. Letters in a row inside a + // password are a word far more often than they are a secret, and refusing to read + // them would put every language outside this one back where the English list left + // them. + if (readable < MIN_WORD) return run + + // What is left over is too short to be a word of its own, so it is the tail of + // this one. "CEDAR" reads best as "ced", since a great many words end in "ed", and + // leaving "ar" behind charges an attacker for two characters nobody chose + // separately. Only letters are taken this way: the leftover of "Garden@" is a + // respelling and belongs to whatever comes after it. + if (word > 0 && run - word < MIN_WORD && allLetters(raw, from + word, from + run)) + return run + + return word + } + + private fun allLetters(raw: CharArray, from: Int, until: Int): Boolean { + for (index in from until until) if (!raw[index].isLetter()) return false + return true + } + + private fun symbolOf(char: Char) = if (char in 'a'..'z') char - 'a' else -1 + + private fun logProbability(back2: Int, back1: Int, next: Int): Double = + -(table[(back2 * ALPHABET + back1) * ALPHABET + next].toInt() and 0xFF) / SCALE + + private val table: ByteArray by lazy { train() } + + /** + * Counts every letter that followed every pair of letters, then stores the result as + * one byte of surprise per cell. A byte is 1/16th of a nat apart from its neighbour + * and the totals are compared at a distance of whole nats, so the rounding never + * decides anything. + */ + private fun train(): ByteArray { + val counts = IntArray(CELLS) + val totals = IntArray(CONTEXTS) + + // english is ordinary vocabulary, eff_large the list passphrase generators draw + // from. Names, surnames and the leaked password list are deliberately left out: a + // model taught that "qwerty" is ordinary would stop charging for it. + listOf(DictionaryUtil.english, DictionaryUtil.eff_large).forEach { name -> + DictionaryUtil.loadUnrankedDictionary(name).keys.forEach { word -> + val trimmed = word.trim().lowercase() + if (trimmed.length >= MIN_WORD && trimmed.all { it in 'a'..'z' }) + // Trained canonicalised, because that is the form a lookup arrives in. + // Skip this and no word holding an l reads as one: "maple" + // canonicalises to "mapie", since l, 1, | and ! all read as i. + learn(trimmed.toCharArray().canonicalize(), counts, totals) + } + } + + val table = ByteArray(CELLS) + for (context in 0 until CONTEXTS) { + val total = totals[context] + ALPHABET * SMOOTHING + for (next in 0 until ALPHABET) { + val cell = context * ALPHABET + next + val probability = (counts[cell] + SMOOTHING) / total + table[cell] = (-ln(probability) * SCALE).toInt().coerceIn(0, 255).toByte() + } + } + return table + } + + private fun learn(word: CharArray, counts: IntArray, totals: IntArray) { + var back2 = EDGE + var back1 = EDGE + word.forEach { char -> + val symbol = symbolOf(char) + val context = back2 * ALPHABET + back1 + counts[context * ALPHABET + symbol]++ + totals[context]++ + back2 = back1 + back1 = symbol + } + val context = back2 * ALPHABET + back1 + counts[context * ALPHABET + EDGE]++ + totals[context]++ + } + + /** 26 letters and the symbol standing for the edge of a word. */ + private const val ALPHABET = 27 + private const val EDGE = 26 + private const val CONTEXTS = ALPHABET * ALPHABET + private const val CELLS = CONTEXTS * ALPHABET + + /** + * What every letter is credited with having followed every pair, so that nothing is + * impossible and a word built of unseen pairs is merely very expensive. + * + * Deliberately tiny. A letter the training set never put after a pair it has seen + * thousands of times then costs around eleven nats, and that is what separates a token + * from a word: a token is mostly ordinary letters in an order no word takes. Making it + * larger flattens exactly the distinction the model exists to draw. + */ + private const val SMOOTHING = 0.01 + + private const val SCALE = 16.0 + + /** + * What one character of a word would have cost had it been drawn at random, which is + * what every character of a candidate word is measured against. + * + * Seventy, not twenty six, because what a letter of a password competes against is not + * another letter: it is a digit or a symbol that canonicalising turned into one. An + * ordinary character of a word beats this by around two nats. + */ + private val BY_CHANCE = ln(1.0 / 70) + + /** + * What a character costs a word for having been written as something other than a + * letter. + * + * People do write "Dr@gon", and six ordinary characters earn enough to carry one + * respelling, so it still reads as a word. What does not survive is a word reaching + * across a respelling into another one: "Uzb+audible+Uzb" folds to a single run of + * letters and only the crossings say it is three things. + */ + private const val RESPELLING = 6.0 + + /** + * How far the reading may fall below its best before the word is taken to have ended. + * + * Wide, because the reading at a given length asks whether a word ends there, and + * inside a word the honest answer is no: nothing ends in "ceda", so a narrow margin + * would stop before reaching "cedar". What it still catches is a run that has gone well + * past anything a word does. + */ + private const val GIVE_UP = 6.0 + + /** + * How far ahead of chance a run has to read before it is worth calling a word. + * + * A model says yes to anything spelled the way words are spelled, and at three + * characters a great many tokens are: "sparkasse-M4H62C" would give up "mah" and + * "discord EwBiH" would give up "ewbi", and what is left is no longer enough to tell + * two tokens apart. Asking for a real margin rather than a longer run is what keeps + * "oak" and "elk" while turning those down. + */ + private const val WORTH_IT = 4.0 +} diff --git a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/model/BreachedError.kt b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/model/BreachedError.kt new file mode 100644 index 000000000..56fc02616 --- /dev/null +++ b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/model/BreachedError.kt @@ -0,0 +1,8 @@ +package de.davis.keygo.feature.password_health.domain.model + +sealed interface BreachedError { + + data object ApiFailed : BreachedError + data object Unreachable : BreachedError + data object InvalidPrefix : BreachedError +} diff --git a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/model/CheckGap.kt b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/model/CheckGap.kt new file mode 100644 index 000000000..b23758ee2 --- /dev/null +++ b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/model/CheckGap.kt @@ -0,0 +1,8 @@ +package de.davis.keygo.feature.password_health.domain.model + +import de.davis.keygo.core.item.domain.alias.ItemId + +data class CheckGap( + val reason: GapReason, + val unchecked: Set, +) diff --git a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/model/CheckKind.kt b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/model/CheckKind.kt new file mode 100644 index 000000000..1700b0feb --- /dev/null +++ b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/model/CheckKind.kt @@ -0,0 +1,8 @@ +package de.davis.keygo.feature.password_health.domain.model + +sealed interface CheckKind { + data object Strength : CheckKind + data object Reuse : CheckKind + data object Breach : CheckKind + data object Similarity : CheckKind +} diff --git a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/model/CheckOutcome.kt b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/model/CheckOutcome.kt new file mode 100644 index 000000000..94fc93059 --- /dev/null +++ b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/model/CheckOutcome.kt @@ -0,0 +1,17 @@ +package de.davis.keygo.feature.password_health.domain.model + +data class CheckOutcome( + val findings: List = emptyList(), + val gap: CheckGap? = null, +) { + + companion object { + + fun skipped(reason: GapReason, candidates: List) = CheckOutcome( + gap = CheckGap( + reason = reason, + unchecked = candidates.mapTo(mutableSetOf()) { it.id }, + ), + ) + } +} diff --git a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/model/FindingSeverity.kt b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/model/FindingSeverity.kt new file mode 100644 index 000000000..33e281bec --- /dev/null +++ b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/model/FindingSeverity.kt @@ -0,0 +1,7 @@ +package de.davis.keygo.feature.password_health.domain.model + +enum class FindingSeverity { + Medium, + High, + Critical, +} diff --git a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/model/GapReason.kt b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/model/GapReason.kt new file mode 100644 index 000000000..a58117c90 --- /dev/null +++ b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/model/GapReason.kt @@ -0,0 +1,7 @@ +package de.davis.keygo.feature.password_health.domain.model + +sealed interface GapReason { + data object Disabled : GapReason + data object Unreachable : GapReason + data object Failed : GapReason +} diff --git a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/model/HealthFinding.kt b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/model/HealthFinding.kt new file mode 100644 index 000000000..3849762b1 --- /dev/null +++ b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/model/HealthFinding.kt @@ -0,0 +1,15 @@ +package de.davis.keygo.feature.password_health.domain.model + +import de.davis.keygo.core.item.domain.alias.ItemId + +sealed interface HealthFinding { + data class Item( + val id: ItemId, + val issue: ItemIssue, + ) : HealthFinding + + data class Relation( + val relatedItemIds: Set, + val type: RelationType, + ) : HealthFinding +} diff --git a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/model/HealthNotificationState.kt b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/model/HealthNotificationState.kt new file mode 100644 index 000000000..a911be078 --- /dev/null +++ b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/model/HealthNotificationState.kt @@ -0,0 +1,21 @@ +package de.davis.keygo.feature.password_health.domain.model + +import kotlin.time.Instant + +data class HealthNotificationState( + val snapshot: HealthSnapshot? = null, + val reminder: HealthReminder? = null, +) + +data class HealthSnapshot( + val attentionCount: Int, + val breachedCount: Int, + val vault: VaultFingerprint, +) + +data class HealthReminder( + val attentionCount: Int, + val breachedCount: Int, + val sentAt: Instant, + val repeats: Int, +) diff --git a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/model/HealthReportStoreError.kt b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/model/HealthReportStoreError.kt new file mode 100644 index 000000000..f27e4599a --- /dev/null +++ b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/model/HealthReportStoreError.kt @@ -0,0 +1,7 @@ +package de.davis.keygo.feature.password_health.domain.model + +sealed interface HealthReportStoreError { + object NoReportStored : HealthReportStoreError + object CryptoFailed : HealthReportStoreError + object CorruptedReport : HealthReportStoreError +} diff --git a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/model/HealthSettings.kt b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/model/HealthSettings.kt new file mode 100644 index 000000000..be5a343a9 --- /dev/null +++ b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/model/HealthSettings.kt @@ -0,0 +1,6 @@ +package de.davis.keygo.feature.password_health.domain.model + +data class HealthSettings( + val breachesEnabled: Boolean, + val notificationsEnabled: Boolean, +) diff --git a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/model/ItemIssue.kt b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/model/ItemIssue.kt new file mode 100644 index 000000000..7964ce57b --- /dev/null +++ b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/model/ItemIssue.kt @@ -0,0 +1,15 @@ +package de.davis.keygo.feature.password_health.domain.model + +import de.davis.keygo.core.item.domain.model.PasswordScore + +sealed interface ItemIssue { + val severity: FindingSeverity + + data class Weak(val score: PasswordScore) : ItemIssue { + override val severity = FindingSeverity.Medium + } + + data class Breached(val occurrences: Int) : ItemIssue { + override val severity = FindingSeverity.Critical + } +} diff --git a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/model/KeyGoNotification.kt b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/model/KeyGoNotification.kt new file mode 100644 index 000000000..4a6b04ff9 --- /dev/null +++ b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/model/KeyGoNotification.kt @@ -0,0 +1,13 @@ +package de.davis.keygo.feature.password_health.domain.model + +sealed interface KeyGoNotification { + val kind: Kind + + data class NeedsAttention(val count: Int) : KeyGoNotification { + override val kind = Kind.NeedsAttention + } + + enum class Kind { + NeedsAttention, + } +} diff --git a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/model/PasswordCandidate.kt b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/model/PasswordCandidate.kt new file mode 100644 index 000000000..214ef2db5 --- /dev/null +++ b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/model/PasswordCandidate.kt @@ -0,0 +1,10 @@ +package de.davis.keygo.feature.password_health.domain.model + +import de.davis.keygo.core.item.domain.alias.ItemId +import de.davis.keygo.core.item.domain.model.PasswordScore + +class PasswordCandidate( + val id: ItemId, + val score: PasswordScore, + val password: CharArray, +) diff --git a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/model/PasswordFixError.kt b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/model/PasswordFixError.kt new file mode 100644 index 000000000..1a91e99fd --- /dev/null +++ b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/model/PasswordFixError.kt @@ -0,0 +1,6 @@ +package de.davis.keygo.feature.password_health.domain.model + +enum class PasswordFixError { + Locked, + Save, +} diff --git a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/model/PasswordHealthReport.kt b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/model/PasswordHealthReport.kt new file mode 100644 index 000000000..0c4c726f2 --- /dev/null +++ b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/model/PasswordHealthReport.kt @@ -0,0 +1,56 @@ +package de.davis.keygo.feature.password_health.domain.model + +import de.davis.keygo.core.item.domain.alias.ItemId +import de.davis.keygo.core.util.domain.comparator.NaturalOrderComparator + +data class ItemHealth( + val itemId: ItemId, + val title: String, + val username: String?, + val issues: List, + val urls: List = emptyList(), +) { + val maxSeverity: FindingSeverity? = issues.maxOfOrNull { it.severity } + + val breach: ItemIssue.Breached? = issues.filterIsInstance().firstOrNull() + val weak: ItemIssue.Weak? = issues.filterIsInstance().firstOrNull() +} + +data class RelatedGroup( + val id: ItemId, + val members: Set, + val relations: Set, +) { + val maxSeverity = + (members.mapNotNull { it.maxSeverity } + relations.map { it.type.severity }).max() + + /** + * What the group is mostly about, which is what its label says. + * + * A group is one connected component over every relation edge, so a couple of exact + * copies can sit inside a much wider similarity chain. + */ + val dominantRelation: RelationType = relations + .groupBy { it.type } + .entries + .maxWith( + compareBy>> { (_, sharing) -> + sharing.flatMapTo(mutableSetOf()) { it.relatedItemIds }.size + }.thenBy { (type, _) -> type.severity }, + ) + .key + val orderedMembers: List = members.sortedWith( + compareByDescending { it.maxSeverity } + .thenBy(NaturalOrderComparator) { it.title }, + ) +} + +data class PasswordHealthReport( + val groups: List, + val standalone: List, + val totalPasswordsScanned: Int, + val gaps: Map = emptyMap(), + val unreadable: Set = emptySet(), +) { + val needsAttentionCount: Int = groups.sumOf { it.members.size } + standalone.size +} diff --git a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/model/PasswordHealthReportError.kt b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/model/PasswordHealthReportError.kt new file mode 100644 index 000000000..f476f9582 --- /dev/null +++ b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/model/PasswordHealthReportError.kt @@ -0,0 +1,6 @@ +package de.davis.keygo.feature.password_health.domain.model + +sealed interface PasswordHealthReportError { + data object NoPasswords : PasswordHealthReportError + data object Unreadable : PasswordHealthReportError +} diff --git a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/model/RelationType.kt b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/model/RelationType.kt new file mode 100644 index 000000000..bc9d01f9c --- /dev/null +++ b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/model/RelationType.kt @@ -0,0 +1,6 @@ +package de.davis.keygo.feature.password_health.domain.model + +enum class RelationType(val severity: FindingSeverity) { + Reused(FindingSeverity.High), + Similar(FindingSeverity.Medium), +} diff --git a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/model/StoredHealthReport.kt b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/model/StoredHealthReport.kt new file mode 100644 index 000000000..6586b60ca --- /dev/null +++ b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/model/StoredHealthReport.kt @@ -0,0 +1,87 @@ +package de.davis.keygo.feature.password_health.domain.model + +import de.davis.keygo.core.item.domain.alias.ItemId +import de.davis.keygo.core.item.domain.model.PasswordScore +import kotlin.time.Duration.Companion.days +import kotlin.time.Instant + +data class StoredHealthReport( + val items: List, + val relationalFindings: List, + val gaps: Map = emptyMap(), + val breachCheckEnabled: Boolean, +) { + + val totalPasswordsScanned = items.size + + fun isFreshFor( + fingerprints: Map, + breachCheckEnabled: Boolean, + now: Instant, + ): Boolean = isCurrentFor(fingerprints, breachCheckEnabled) + && (!breachCheckEnabled || items.all { it.breach?.isCurrentAt(now) == true }) + + fun isCurrentFor( + fingerprints: Map, + breachCheckEnabled: Boolean, + ): Boolean = this.breachCheckEnabled == breachCheckEnabled + && items.none { it.unreadable } // if any item was unreadable, we want a new report + && !hasGapNeedingFullScan + && items.associate { it.id to it.fingerprint } == fingerprints + + fun breachResultsAt( + fingerprints: Map, + now: Instant, + ): Map = items + .filter { fingerprints[it.id] == it.fingerprint } + .mapNotNull { item -> item.breach?.takeIf { it.isCurrentAt(now) }?.let { item.id to it } } + .toMap() + + // Breach gaps are left out: their items have no BreachResult, so the scanner looks up just + // those again in refreshBreaches instead of rescanning everything. + private val hasGapNeedingFullScan: Boolean + get() = gaps + .filterKeys { it != CheckKind.Breach } + .values + .any { it.reason != GapReason.Disabled } + + companion object { + const val ALGORITHM_VERSION = 1 + } +} + +data class StoredHealthItem( + val id: ItemId, + val fingerprint: HealthFingerprint, + val score: PasswordScore?, + val breach: BreachResult?, + val unreadable: Boolean, +) + +data class BreachResult( + val occurrences: Int, + val checkedAt: Instant, +) { + + fun isCurrentAt(now: Instant): Boolean = now - checkedAt < TTL + + companion object { + val TTL = 1.days + } +} + +class HealthFingerprint(val value: ByteArray) { + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as HealthFingerprint + + return value.contentEquals(other.value) + } + + override fun hashCode(): Int { + return value.contentHashCode() + } +} diff --git a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/model/VaultFingerprint.kt b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/model/VaultFingerprint.kt new file mode 100644 index 000000000..015f648df --- /dev/null +++ b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/model/VaultFingerprint.kt @@ -0,0 +1,41 @@ +package de.davis.keygo.feature.password_health.domain.model + +import java.security.MessageDigest + +class VaultFingerprint(val value: ByteArray) { + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as VaultFingerprint + + return value.contentEquals(other.value) + } + + override fun hashCode(): Int = value.contentHashCode() + + companion object { + + fun of(fingerprints: Collection): VaultFingerprint { + val digest = MessageDigest.getInstance("SHA-256") + fingerprints + .map { it.value } + .sortedWith(UnsignedBytes) + .forEach(digest::update) + + return VaultFingerprint(digest.digest()) + } + + // Arrays.compareUnsigned would do, but only exists from API 33. + private object UnsignedBytes : Comparator { + override fun compare(a: ByteArray, b: ByteArray): Int { + for (i in 0 until minOf(a.size, b.size)) { + val diff = (a[i].toInt() and 0xFF) - (b[i].toInt() and 0xFF) + if (diff != 0) return diff + } + return a.size - b.size + } + } + } +} diff --git a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/report/HealthReportAssembler.kt b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/report/HealthReportAssembler.kt new file mode 100644 index 000000000..10f772354 --- /dev/null +++ b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/report/HealthReportAssembler.kt @@ -0,0 +1,90 @@ +package de.davis.keygo.feature.password_health.domain.report + +import de.davis.keygo.core.item.domain.alias.ItemId +import de.davis.keygo.core.item.domain.model.Login +import de.davis.keygo.feature.password_health.domain.model.ItemHealth +import de.davis.keygo.feature.password_health.domain.model.ItemIssue +import de.davis.keygo.feature.password_health.domain.model.PasswordHealthReport +import de.davis.keygo.feature.password_health.domain.model.RelatedGroup +import de.davis.keygo.feature.password_health.domain.model.StoredHealthItem +import de.davis.keygo.feature.password_health.domain.model.StoredHealthReport +import org.koin.core.annotation.Single + +@Single +class HealthReportAssembler { + + fun assemble(report: StoredHealthReport, logins: List): PasswordHealthReport { + val loginsById = logins.associateBy { it.id } + + val issuesById = report.items.filterFindings() + .filter { it.id in loginsById } + .associate { it.id to it.issues() } + + val relations = report.relationalFindings.mapNotNull { + val surviving = it.relatedItemIds.filterTo(mutableSetOf()) { it in loginsById } + if (surviving.size > 1) it.copy(relatedItemIds = surviving) else null + } + + val uf = UnionFind() + relations.forEach { relation -> + val first = relation.relatedItemIds.first() + relation.relatedItemIds.forEach { uf.union(first, it) } + } + + fun health(item: StoredHealthItem): ItemHealth { + val login = loginsById.getValue(item.id) + return ItemHealth( + itemId = item.id, + title = login.name, + username = login.username, + issues = issuesById[item.id].orEmpty().sortedByDescending { it.severity }, + urls = login.domainInfos.map { it.value }, + ) + } + + val (related, unrelated) = report.items.partition { uf.contains(it.id) } + val relationsById = relations.groupBy { uf.find(it.relatedItemIds.first()) } + + val groups = related.groupBy { uf.find(it.id) } + .map { (root, members) -> + RelatedGroup( + id = root, + members = members.mapTo(mutableSetOf(), ::health), + relations = relationsById.getValue(root).toSet(), + ) + } + .sortedByDescending { it.maxSeverity } + + val standalone = unrelated.filter { it.id in loginsById } + .map(::health) + .filter { it.issues.isNotEmpty() } + .sortedByDescending { it.maxSeverity } + + val survivingGaps = report.gaps.mapNotNull { (kind, gap) -> + val unchecked = gap.unchecked.filterTo(mutableSetOf()) { it in loginsById } + if (unchecked.isNotEmpty()) kind to gap.copy(unchecked = unchecked) else null + }.toMap() + + return PasswordHealthReport( + groups = groups, + standalone = standalone, + totalPasswordsScanned = report.totalPasswordsScanned, + gaps = survivingGaps, + unreadable = report.items.filter { it.unreadable } + .mapNotNullTo(mutableSetOf()) { it.id.takeIf { id -> id in loginsById } }, + ) + } + + private fun Collection.filterFindings() = filter { + (it.score != null && !it.score.isNone) || + it.breachOccurrences > 0 + } + + private val StoredHealthItem.breachOccurrences + get() = breach?.occurrences ?: 0 + + private fun StoredHealthItem.issues() = buildList { + score?.takeIf { !it.isNone }?.let { add(ItemIssue.Weak(it)) } + if (breachOccurrences > 0) add(ItemIssue.Breached(breachOccurrences)) + } +} diff --git a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/report/HealthReportScanner.kt b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/report/HealthReportScanner.kt new file mode 100644 index 000000000..2a7bc2cf5 --- /dev/null +++ b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/report/HealthReportScanner.kt @@ -0,0 +1,193 @@ +package de.davis.keygo.feature.password_health.domain.report + +import de.davis.keygo.core.item.domain.alias.ItemId +import de.davis.keygo.core.item.domain.model.Login +import de.davis.keygo.core.security.domain.crypto.CryptographicScopeProvider +import de.davis.keygo.core.security.domain.crypto.decrypt +import de.davis.keygo.core.security.domain.model.CryptoScopeError +import de.davis.keygo.core.util.Result +import de.davis.keygo.core.util.asResult +import de.davis.keygo.core.util.getOrNull +import de.davis.keygo.core.util.isFailure +import de.davis.keygo.core.util.resultBinding +import de.davis.keygo.feature.password_health.domain.checker.PasswordHealthChecker +import de.davis.keygo.feature.password_health.domain.model.BreachResult +import de.davis.keygo.feature.password_health.domain.model.CheckKind +import de.davis.keygo.feature.password_health.domain.model.CheckOutcome +import de.davis.keygo.feature.password_health.domain.model.HealthFinding +import de.davis.keygo.feature.password_health.domain.model.HealthFingerprint +import de.davis.keygo.feature.password_health.domain.model.ItemIssue +import de.davis.keygo.feature.password_health.domain.model.PasswordCandidate +import de.davis.keygo.feature.password_health.domain.model.PasswordHealthReportError +import de.davis.keygo.feature.password_health.domain.model.StoredHealthItem +import de.davis.keygo.feature.password_health.domain.model.StoredHealthReport +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope +import org.koin.core.annotation.Single +import kotlin.time.Instant + +@Single +class HealthReportScanner( + private val cryptographicScopeProvider: CryptographicScopeProvider, + private val checkers: List, +) { + + suspend fun scan( + logins: Collection, + fingerprints: Map, + previous: StoredHealthReport?, + breachCheckEnabled: Boolean, + now: Instant, + ): Result = resultBinding { + val keptBreaches = previous + ?.takeIf { breachCheckEnabled } + ?.breachResultsAt(fingerprints, now) + .orEmpty() + + // Strength, reuse and similarity only move with the passwords, so a report that still + // matches them is kept and only the breach lookups that ran out are repeated. + val current = previous?.takeIf { it.isCurrentFor(fingerprints, breachCheckEnabled) } + + if (current != null) refreshBreaches(current, logins, keptBreaches, now) + else fullScan(logins, fingerprints, keptBreaches, breachCheckEnabled, now).bind() + } + + private suspend fun fullScan( + logins: Collection, + fingerprints: Map, + keptBreaches: Map, + breachCheckEnabled: Boolean, + now: Instant, + ): Result = resultBinding { + val decrypted = decrypt(logins) + decrypted.candidates.isNotEmpty().asResult( + if (decrypted.unreadable.isEmpty()) PasswordHealthReportError.NoPasswords + else PasswordHealthReportError.Unreadable, + ).bind() + + val outcomes = check(checkers, decrypted.candidates, keptBreaches) + val findings = outcomes.values.flatMap { it.findings } + val weak = findings.issues() + val breaches = outcomes.breachResults(decrypted.candidates, keptBreaches, now) + + StoredHealthReport( + items = logins.mapNotNull { login -> + fingerprints[login.id]?.let { fingerprint -> + StoredHealthItem( + id = login.id, + fingerprint = fingerprint, + score = weak[login.id]?.score, + breach = breaches[login.id], + unreadable = login.id in decrypted.unreadable, + ) + } + }, + relationalFindings = findings.filterIsInstance(), + gaps = outcomes.gaps(), + breachCheckEnabled = breachCheckEnabled, + ) + } + + private suspend fun refreshBreaches( + current: StoredHealthReport, + logins: Collection, + keptBreaches: Map, + now: Instant, + ): StoredHealthReport { + val decrypted = decrypt(logins.filter { it.id !in keptBreaches }) + val breachCheckers = checkers.filter { it.type == CheckKind.Breach } + + val outcomes = check(breachCheckers, decrypted.candidates, keptBreaches) + val breaches = outcomes.breachResults(decrypted.candidates, keptBreaches, now) + + return current.copy( + items = current.items.map { + it.copy(breach = breaches[it.id], unreadable = it.id in decrypted.unreadable) + }, + gaps = current.gaps - CheckKind.Breach + outcomes.gaps(), + ) + } + + private suspend fun decrypt(logins: Collection): Decrypted { + val results = coroutineScope { + logins.map { login -> async { login.id to candidate(login) } }.awaitAll() + } + + // A password that will not decrypt is checked by nobody, so it is reported instead + // of quietly dropping out of the count. + return Decrypted( + candidates = results.mapNotNull { (_, result) -> result?.getOrNull() }, + unreadable = results + .filter { (_, result) -> result?.isFailure() == true } + .mapTo(mutableSetOf()) { (id, _) -> id }, + ) + } + + private suspend fun check( + checkers: List, + candidates: List, + keptBreaches: Map, + ): Map = try { + coroutineScope { + checkers.map { checker -> + async { + // Without the filter, the breach checker would look up every password again + // after any edit, and incremental breach lookups would no longer happen. + val input = if (checker.type == CheckKind.Breach) + candidates.filter { it.id !in keptBreaches } + else candidates + + checker.type to checker.check(input) + } + }.awaitAll().toMap() + } + } finally { + candidates.forEach { it.password.fill('\u0000') } + } + + private fun Map.breachResults( + candidates: List, + keptBreaches: Map, + now: Instant, + ): Map { + val outcome = this[CheckKind.Breach] ?: return keptBreaches + val breached = outcome.findings.issues() + val unchecked = outcome.gap?.unchecked.orEmpty() + + return keptBreaches + candidates.map { it.id } + .filter { it !in keptBreaches && it !in unchecked } + .associateWith { + BreachResult( + occurrences = breached[it]?.occurrences ?: 0, + checkedAt = now + ) + } + } + + private fun Map.gaps() = + mapNotNull { (kind, outcome) -> outcome.gap?.let { kind to it } }.toMap() + + private inline fun List.issues(): Map = + filterIsInstance() + .mapNotNull { finding -> (finding.issue as? T)?.let { finding.id to it } } + .toMap() + + private suspend fun candidate(login: Login): Result? = + login.passwordCredential?.let { credential -> + cryptographicScopeProvider.itemScope( + itemId = login.id, + ) { + PasswordCandidate( + id = login.id, + score = credential.score, + password = credential.secret.decrypt().toCharArray(), + ) + } + } + + private class Decrypted( + val candidates: List, + val unreadable: Set, + ) +} diff --git a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/report/UnionFind.kt b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/report/UnionFind.kt new file mode 100644 index 000000000..d3d01aa77 --- /dev/null +++ b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/report/UnionFind.kt @@ -0,0 +1,17 @@ +package de.davis.keygo.feature.password_health.domain.report + +internal class UnionFind { + private val parent = HashMap() + fun contains(x: T) = x in parent + + fun find(x: T): T { + val p = parent.getOrPut(x) { x } + return if (p == x) x else find(p).also { parent[x] = it } + } + + fun union(a: T, b: T) { + val ra = find(a) + val rb = find(b) + if (ra != rb) parent[ra] = rb + } +} diff --git a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/repository/BreachedRepository.kt b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/repository/BreachedRepository.kt new file mode 100644 index 000000000..f225f3943 --- /dev/null +++ b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/repository/BreachedRepository.kt @@ -0,0 +1,16 @@ +package de.davis.keygo.feature.password_health.domain.repository + +import de.davis.keygo.core.util.Result +import de.davis.keygo.feature.password_health.domain.model.BreachedError + +interface BreachedRepository { + + suspend fun occurrences( + prefix: String, + suffixes: Set, + ): Result, BreachedError> + + companion object { + const val PREFIX_LENGTH = 5 + } +} diff --git a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/repository/ConnectivityRepository.kt b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/repository/ConnectivityRepository.kt new file mode 100644 index 000000000..8582b5a59 --- /dev/null +++ b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/repository/ConnectivityRepository.kt @@ -0,0 +1,7 @@ +package de.davis.keygo.feature.password_health.domain.repository + +import kotlinx.coroutines.flow.Flow + +interface ConnectivityRepository { + fun observeInternet(): Flow +} diff --git a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/repository/HealthNotificationStateRepository.kt b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/repository/HealthNotificationStateRepository.kt new file mode 100644 index 000000000..360e4bb79 --- /dev/null +++ b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/repository/HealthNotificationStateRepository.kt @@ -0,0 +1,12 @@ +package de.davis.keygo.feature.password_health.domain.repository + +import de.davis.keygo.feature.password_health.domain.model.HealthNotificationState +import de.davis.keygo.feature.password_health.domain.model.HealthReminder +import de.davis.keygo.feature.password_health.domain.model.HealthSnapshot + +interface HealthNotificationStateRepository { + suspend fun get(): HealthNotificationState + suspend fun setSnapshot(snapshot: HealthSnapshot?) + suspend fun setReminder(reminder: HealthReminder?) + suspend fun clear() +} diff --git a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/repository/HealthReportStoreRepository.kt b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/repository/HealthReportStoreRepository.kt new file mode 100644 index 000000000..4aba0b2e6 --- /dev/null +++ b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/repository/HealthReportStoreRepository.kt @@ -0,0 +1,11 @@ +package de.davis.keygo.feature.password_health.domain.repository + +import de.davis.keygo.core.util.Result +import de.davis.keygo.feature.password_health.domain.model.HealthReportStoreError +import de.davis.keygo.feature.password_health.domain.model.StoredHealthReport + +interface HealthReportStoreRepository { + + suspend fun storeReport(report: StoredHealthReport): Result + suspend fun load(): Result +} diff --git a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/repository/HealthSettingsRepository.kt b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/repository/HealthSettingsRepository.kt new file mode 100644 index 000000000..052b87f58 --- /dev/null +++ b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/repository/HealthSettingsRepository.kt @@ -0,0 +1,11 @@ +package de.davis.keygo.feature.password_health.domain.repository + +import de.davis.keygo.feature.password_health.domain.model.HealthSettings +import kotlinx.coroutines.flow.Flow + +interface HealthSettingsRepository { + fun observeBreachCheckState(): Flow + suspend fun getBreachCheckState(): HealthSettings + suspend fun setBreachEnabled(enabled: Boolean) + suspend fun setNotificationEnabled(enabled: Boolean) +} diff --git a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/usecase/PasswordHealthReportUseCase.kt b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/usecase/PasswordHealthReportUseCase.kt new file mode 100644 index 000000000..85f5fbc2a --- /dev/null +++ b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/usecase/PasswordHealthReportUseCase.kt @@ -0,0 +1,134 @@ +package de.davis.keygo.feature.password_health.domain.usecase + +import android.util.Log +import de.davis.keygo.core.item.domain.repository.LoginRepository +import de.davis.keygo.core.util.Result +import de.davis.keygo.core.util.asResult +import de.davis.keygo.core.util.getOrNull +import de.davis.keygo.core.util.mapSuccess +import de.davis.keygo.core.util.onFailure +import de.davis.keygo.core.util.onSuccess +import de.davis.keygo.core.util.resultBinding +import de.davis.keygo.feature.password_health.domain.HealthCheckNotifierScheduler +import de.davis.keygo.feature.password_health.domain.LoginFingerprinter +import de.davis.keygo.feature.password_health.domain.PasswordHealthAttention +import de.davis.keygo.feature.password_health.domain.model.HealthSnapshot +import de.davis.keygo.feature.password_health.domain.model.PasswordHealthReport +import de.davis.keygo.feature.password_health.domain.model.PasswordHealthReportError +import de.davis.keygo.feature.password_health.domain.model.VaultFingerprint +import de.davis.keygo.feature.password_health.domain.report.HealthReportAssembler +import de.davis.keygo.feature.password_health.domain.report.HealthReportScanner +import de.davis.keygo.feature.password_health.domain.repository.HealthNotificationStateRepository +import de.davis.keygo.feature.password_health.domain.repository.HealthReportStoreRepository +import de.davis.keygo.feature.password_health.domain.repository.HealthSettingsRepository +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import org.koin.core.annotation.Single +import kotlin.coroutines.CoroutineContext +import kotlin.time.Clock + +@Single +class PasswordHealthReportUseCase( + private val loginRepository: LoginRepository, + private val loginFingerprinter: LoginFingerprinter, + private val healthReportStoreRepository: HealthReportStoreRepository, + private val healthSettingsRepository: HealthSettingsRepository, + private val scanner: HealthReportScanner, + private val assembler: HealthReportAssembler, + private val attention: PasswordHealthAttention, + private val healthNotificationStateRepository: HealthNotificationStateRepository, + private val healthCheckNotifierScheduler: HealthCheckNotifierScheduler, +) { + + private val mutex = Mutex() + + suspend operator fun invoke( + force: Boolean, + coroutineContext: CoroutineContext = Dispatchers.Default, + ): Result = mutex.withLock { + report(force, coroutineContext) + .onSuccess { (report, vault) -> + attention.update(report.needsAttentionCount) + recordSnapshot(report.snapshot(vault)) + } + .onFailure { + if (it == PasswordHealthReportError.NoPasswords) { + attention.clear() + recordSnapshot(null) + } + } + .mapSuccess { it.report } + } + + private suspend fun recordSnapshot(snapshot: HealthSnapshot?) { + if (!healthSettingsRepository.getBreachCheckState().notificationsEnabled) return + + healthNotificationStateRepository.setSnapshot(snapshot) + // The setting outlives the schedule across a device restore; re-arming here brings it back. + healthCheckNotifierScheduler.scheduleHealthReminder() + } + + private suspend fun report( + force: Boolean, + coroutineContext: CoroutineContext, + ): Result = resultBinding { + withContext(coroutineContext) { + val breachCheckEnabled = async { + healthSettingsRepository.getBreachCheckState().breachesEnabled + } + val storedReport = async { + if (force) null else healthReportStoreRepository.load().getOrNull() + } + + val logins = loginRepository.observeLogins() + .firstOrNull() + ?.filter { it.passwordCredential != null } + .asResult(PasswordHealthReportError.NoPasswords) + .bind() + + val fingerprints = logins.map { login -> + async { loginFingerprinter.fingerprint(login)?.let { login.id to it } } + } + .awaitAll() + .filterNotNull() + .toMap() + + val vault = VaultFingerprint.of(fingerprints.values) + val now = Clock.System.now() + val previous = storedReport.await() + previous?.takeIf { it.isFreshFor(fingerprints, breachCheckEnabled.await(), now) } + ?.let { return@withContext Scan(assembler.assemble(it, logins), vault) } + + val report = scanner.scan( + logins = logins, + fingerprints = fingerprints, + previous = previous, + breachCheckEnabled = breachCheckEnabled.await(), + now = now, + ).bind() + + // The store is only a cache, so failing to write it must not cost the user the report. + healthReportStoreRepository.storeReport(report).onFailure { + Log.w(TAG, "Failed to store password health report: $it") + } + Scan(assembler.assemble(report, logins), vault) + } + } + + private data class Scan(val report: PasswordHealthReport, val vault: VaultFingerprint) + + private fun PasswordHealthReport.snapshot(vault: VaultFingerprint) = HealthSnapshot( + attentionCount = needsAttentionCount, + breachedCount = (groups.flatMap { it.members } + standalone).count { it.breach != null }, + vault = vault, + ) + + companion object { + private const val TAG = "PasswordHealthReportUseCase" + } +} diff --git a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/usecase/SendHealthReminderUseCase.kt b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/usecase/SendHealthReminderUseCase.kt new file mode 100644 index 000000000..59061e4ab --- /dev/null +++ b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/usecase/SendHealthReminderUseCase.kt @@ -0,0 +1,88 @@ +package de.davis.keygo.feature.password_health.domain.usecase + +import de.davis.keygo.core.item.domain.repository.LoginRepository +import de.davis.keygo.feature.password_health.domain.LoginFingerprinter +import de.davis.keygo.feature.password_health.domain.Notifier +import de.davis.keygo.feature.password_health.domain.model.HealthReminder +import de.davis.keygo.feature.password_health.domain.model.HealthSnapshot +import de.davis.keygo.feature.password_health.domain.model.KeyGoNotification +import de.davis.keygo.feature.password_health.domain.model.VaultFingerprint +import de.davis.keygo.feature.password_health.domain.repository.HealthNotificationStateRepository +import de.davis.keygo.feature.password_health.domain.repository.HealthSettingsRepository +import kotlinx.coroutines.flow.first +import org.koin.core.annotation.Single +import kotlin.time.Clock +import kotlin.time.Duration.Companion.days +import kotlin.time.Instant + +@Single +class SendHealthReminderUseCase( + private val loginRepository: LoginRepository, + private val loginFingerprinter: LoginFingerprinter, + private val healthSettingsRepository: HealthSettingsRepository, + private val healthNotificationStateRepository: HealthNotificationStateRepository, + private val notifier: Notifier, +) { + + suspend operator fun invoke(now: Instant = Clock.System.now()) { + if (!healthSettingsRepository.getBreachCheckState().notificationsEnabled) return + if (!notifier.canNotify()) return + + val state = healthNotificationStateRepository.get() + val snapshot = state.snapshot + if (snapshot == null || snapshot.attentionCount == 0) return withdraw() + + // Edited passwords would make the counts lie; the next unlock takes a new snapshot. + if (snapshot.vault != currentVault()) return + + val last = state.reminder + when { + last == null || snapshot.isWorseThan(last) -> notify(snapshot, now, repeats = 0) + last.isDue(now) -> notify(snapshot, now, repeats = last.repeats + 1) + else -> lowerBaseline(last, snapshot) + } + } + + private suspend fun notify(snapshot: HealthSnapshot, now: Instant, repeats: Int) { + notifier.sendNotification(KeyGoNotification.NeedsAttention(count = snapshot.attentionCount)) + healthNotificationStateRepository.setReminder( + HealthReminder( + attentionCount = snapshot.attentionCount, + breachedCount = snapshot.breachedCount, + sentAt = now, + repeats = repeats, + ), + ) + } + + private suspend fun withdraw() { + notifier.cancel(KeyGoNotification.Kind.NeedsAttention) + healthNotificationStateRepository.setReminder(null) + } + + // Issues were fixed since the last notification. Comparing against the old, higher counts + // would hide a new issue that only brings the total back up, so compare against today's. + // The reminder clock is kept: fixing things must not earn the user an extra notification. + private suspend fun lowerBaseline(last: HealthReminder, snapshot: HealthSnapshot) { + val lowered = last.copy( + attentionCount = snapshot.attentionCount, + breachedCount = snapshot.breachedCount, + ) + if (lowered != last) healthNotificationStateRepository.setReminder(lowered) + } + + private suspend fun currentVault(): VaultFingerprint = VaultFingerprint.of( + loginRepository.observeLogins().first().mapNotNull { loginFingerprinter.fingerprint(it) }, + ) + + private fun HealthSnapshot.isWorseThan(reminder: HealthReminder) = + attentionCount > reminder.attentionCount || breachedCount > reminder.breachedCount + + private fun HealthReminder.isDue(now: Instant) = + repeats < MAX_REPEATS && now - sentAt >= REMIND_AFTER + + companion object { + val REMIND_AFTER = 30.days + const val MAX_REPEATS = 2 + } +} diff --git a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/usecase/SetHealthNotificationsUseCase.kt b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/usecase/SetHealthNotificationsUseCase.kt new file mode 100644 index 000000000..0ded9299e --- /dev/null +++ b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/usecase/SetHealthNotificationsUseCase.kt @@ -0,0 +1,29 @@ +package de.davis.keygo.feature.password_health.domain.usecase + +import de.davis.keygo.feature.password_health.domain.HealthCheckNotifierScheduler +import de.davis.keygo.feature.password_health.domain.Notifier +import de.davis.keygo.feature.password_health.domain.model.KeyGoNotification +import de.davis.keygo.feature.password_health.domain.repository.HealthNotificationStateRepository +import de.davis.keygo.feature.password_health.domain.repository.HealthSettingsRepository +import org.koin.core.annotation.Single + +@Single +class SetHealthNotificationsUseCase( + private val healthSettingsRepository: HealthSettingsRepository, + private val healthNotificationStateRepository: HealthNotificationStateRepository, + private val scheduler: HealthCheckNotifierScheduler, + private val notifier: Notifier, +) { + + suspend operator fun invoke(enabled: Boolean) { + healthSettingsRepository.setNotificationEnabled(enabled) + if (enabled) scheduler.scheduleHealthReminder() + else turnOff() + } + + private suspend fun turnOff() { + scheduler.cancel() + healthNotificationStateRepository.clear() + notifier.cancel(KeyGoNotification.Kind.NeedsAttention) + } +} diff --git a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/PasswordHealthContent.kt b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/PasswordHealthContent.kt new file mode 100644 index 000000000..4ca8a3f55 --- /dev/null +++ b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/PasswordHealthContent.kt @@ -0,0 +1,152 @@ +package de.davis.keygo.feature.password_health.presentation + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.consumeWindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.material3.ListItemDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.MediumFlexibleTopAppBar +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.material3.pulltorefresh.PullToRefreshBox +import androidx.compose.runtime.Composable +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.platform.LocalClipboard +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import de.davis.keygo.core.item.domain.alias.ItemId +import de.davis.keygo.core.item.domain.alias.newItemId +import de.davis.keygo.core.item.domain.model.PasswordScore +import de.davis.keygo.core.ui.clipboard.setText +import de.davis.keygo.feature.item.create.presentation.password.GeneratePasswordModalBottomSheet +import de.davis.keygo.feature.password_health.R +import de.davis.keygo.feature.password_health.domain.model.FindingSeverity +import de.davis.keygo.feature.password_health.domain.model.ItemHealth +import de.davis.keygo.feature.password_health.domain.model.ItemIssue +import de.davis.keygo.feature.password_health.presentation.component.HealthSettings +import de.davis.keygo.feature.password_health.presentation.component.PasswordHealthStatus +import de.davis.keygo.feature.password_health.presentation.component.needsAttentionSection +import de.davis.keygo.feature.password_health.presentation.model.HealthSection +import de.davis.keygo.feature.password_health.presentation.model.PasswordHealthStatus +import de.davis.keygo.feature.password_health.presentation.model.PasswordHealthUiEvent +import de.davis.keygo.feature.password_health.presentation.model.PasswordHealthUiState +import de.davis.keygo.feature.password_health.presentation.model.verdict +import kotlinx.coroutines.launch + +@Composable +internal fun PasswordHealthContent( + state: PasswordHealthUiState, + openItemId: ItemId?, + onEvent: (PasswordHealthUiEvent) -> Unit, +) { + val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior() + val clipboard = LocalClipboard.current + val scope = rememberCoroutineScope() + val copiedLabel = stringResource(R.string.fix_copied_label) + Scaffold( + modifier = Modifier.fillMaxSize(), + topBar = { + MediumFlexibleTopAppBar( + title = { + Text(text = stringResource(R.string.password_health_title)) + }, + subtitle = { + Text(text = state.verdict(), maxLines = 1, overflow = TextOverflow.Ellipsis) + }, + scrollBehavior = scrollBehavior, + ) + } + ) { innerPadding -> + PullToRefreshBox( + isRefreshing = state.isRefreshing, + onRefresh = { onEvent(PasswordHealthUiEvent.RefreshHealthCheck) }, + modifier = Modifier + .fillMaxSize() + .padding(innerPadding) + .consumeWindowInsets(innerPadding) + ) { + LazyColumn( + modifier = Modifier + .fillMaxSize() + .nestedScroll(scrollBehavior.nestedScrollConnection), + contentPadding = PaddingValues(8.dp), + verticalArrangement = Arrangement.spacedBy(ListItemDefaults.SegmentedGap), + ) { + item(key = "status") { + PasswordHealthStatus(state = state, modifier = Modifier.animateItem()) + } + + if (state.status == PasswordHealthStatus.NEEDS_ATTENTION) needsAttentionSection( + sections = state.healthSections, + pendingFix = state.pendingFix, + openItemId = openItemId, + onEvent = onEvent, + ) + + item(key = "settings") { + Spacer(modifier = Modifier.height(28.dp)) + HealthSettings( + state = state, + onEvent = onEvent, + modifier = Modifier.animateItem() + ) + } + } + } + } + + if (state.generatingFix != null) GeneratePasswordModalBottomSheet( + onGenerated = { password -> + scope.launch { + clipboard.setText(label = copiedLabel, text = password, sensitive = true) + } + onEvent(PasswordHealthUiEvent.PasswordGenerated(password)) + }, + onDismiss = { onEvent(PasswordHealthUiEvent.DismissGeneratePassword) }, + ) +} + +@Preview +@Composable +private fun PasswordHealthContentPreview() { + MaterialTheme { + Surface( + modifier = Modifier.fillMaxSize(), + ) { + PasswordHealthContent( + state = PasswordHealthUiState( + totalPasswordCount = 12, + reportedSections = listOf( + HealthSection( + severity = FindingSeverity.Medium, + groups = emptyList(), + standalone = listOf( + ItemHealth( + itemId = newItemId(), + title = "Weak password", + username = null, + issues = listOf( + ItemIssue.Weak(score = PasswordScore.Weak) + ) + ) + ) + ) + ), + ), + openItemId = null, + onEvent = {}, + ) + } + } +} diff --git a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/PasswordHealthEntries.kt b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/PasswordHealthEntries.kt new file mode 100644 index 000000000..882e86135 --- /dev/null +++ b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/PasswordHealthEntries.kt @@ -0,0 +1,21 @@ +package de.davis.keygo.feature.password_health.presentation + +import androidx.navigation3.runtime.EntryProviderScope +import androidx.navigation3.runtime.NavKey +import de.davis.keygo.core.item.domain.alias.ItemId +import kotlinx.serialization.Serializable + +@Serializable +object PasswordHealthRoute : NavKey + +const val ACTION_OPEN_PASSWORD_HEALTH = "de.davis.keygo.action.OPEN_PASSWORD_HEALTH" + +fun EntryProviderScope.passwordHealthEntries( + metadata: Map = emptyMap(), + openItemId: ItemId?, + openItem: (ItemId) -> Unit, +) { + entry(metadata = metadata) { + PasswordHealthScreen(openItemId = openItemId, openItem = openItem) + } +} diff --git a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/PasswordHealthScreen.kt b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/PasswordHealthScreen.kt new file mode 100644 index 000000000..72b168ca5 --- /dev/null +++ b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/PasswordHealthScreen.kt @@ -0,0 +1,34 @@ +package de.davis.keygo.feature.password_health.presentation + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import de.davis.keygo.core.item.domain.alias.ItemId +import de.davis.keygo.core.ui.composition.LocalIsInSinglePaneMode +import de.davis.keygo.core.util.presentation.ObserveAsEvents +import de.davis.keygo.feature.password_health.presentation.model.PasswordHealthEvent +import de.davis.keygo.feature.password_health.presentation.model.PasswordHealthUiEvent +import org.koin.androidx.compose.koinViewModel + +@Composable +fun PasswordHealthScreen(openItemId: ItemId?, openItem: (ItemId) -> Unit) { + val viewModel = koinViewModel() + val state by viewModel.uiState.collectAsStateWithLifecycle() + + LaunchedEffect(Unit) { + viewModel.onEvent(PasswordHealthUiEvent.RunHealthCheck) + } + + ObserveAsEvents(flow = viewModel.events) { + when (it) { + is PasswordHealthEvent.OpenItem -> openItem(it.itemId) + } + } + + PasswordHealthContent( + state = state, + openItemId = openItemId.takeUnless { LocalIsInSinglePaneMode.current }, + onEvent = viewModel::onEvent, + ) +} diff --git a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/PasswordHealthViewModel.kt b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/PasswordHealthViewModel.kt new file mode 100644 index 000000000..e2a6e1112 --- /dev/null +++ b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/PasswordHealthViewModel.kt @@ -0,0 +1,225 @@ +package de.davis.keygo.feature.password_health.presentation + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import de.davis.keygo.core.util.fold +import de.davis.keygo.feature.item.core.domain.model.ItemUpsertError +import de.davis.keygo.feature.item.core.domain.model.UpsertLogin +import de.davis.keygo.feature.item.core.domain.model.set +import de.davis.keygo.feature.item.core.domain.usecase.CreateNewOrUpdateLoginUseCase +import de.davis.keygo.feature.item.view.domain.WebsiteHandler +import de.davis.keygo.feature.password_health.domain.model.GapReason +import de.davis.keygo.feature.password_health.domain.model.PasswordFixError +import de.davis.keygo.feature.password_health.domain.model.PasswordHealthReport +import de.davis.keygo.feature.password_health.domain.model.PasswordHealthReportError +import de.davis.keygo.feature.password_health.domain.repository.ConnectivityRepository +import de.davis.keygo.feature.password_health.domain.repository.HealthSettingsRepository +import de.davis.keygo.feature.password_health.domain.usecase.PasswordHealthReportUseCase +import de.davis.keygo.feature.password_health.domain.usecase.SetHealthNotificationsUseCase +import de.davis.keygo.feature.password_health.presentation.model.FixFlow +import de.davis.keygo.feature.password_health.presentation.model.PasswordHealthEvent +import de.davis.keygo.feature.password_health.presentation.model.PasswordHealthUiEvent +import de.davis.keygo.feature.password_health.presentation.model.PasswordHealthUiState +import de.davis.keygo.feature.password_health.presentation.model.RunPhase +import de.davis.keygo.feature.password_health.presentation.model.toSections +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.Job +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.dropWhile +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.mapLatest +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import org.koin.core.annotation.KoinViewModel +import kotlin.time.Duration.Companion.seconds + +@KoinViewModel +internal class PasswordHealthViewModel( + private val passwordHealth: PasswordHealthReportUseCase, + private val createNewOrUpdateLogin: CreateNewOrUpdateLoginUseCase, + private val healthSettingsRepository: HealthSettingsRepository, + private val setHealthNotifications: SetHealthNotificationsUseCase, + private val websiteHandler: WebsiteHandler, + connectivityRepository: ConnectivityRepository, +) : ViewModel() { + + private val _eventChannel = Channel(Channel.BUFFERED) + val events = _eventChannel.receiveAsFlow() + + private val _base = MutableStateFlow(PasswordHealthUiState(phase = RunPhase.FirstLoad)) + + val uiState = combine( + _base, + healthSettingsRepository.observeBreachCheckState(), + ) { base, breachCheckState -> + base.copy( + breachCheckEnabled = breachCheckState.breachesEnabled, + notificationEnabled = breachCheckState.notificationsEnabled, + ) + }.stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5_000), + initialValue = _base.value + ) + + private var run: Job? = null + + init { + @OptIn(ExperimentalCoroutinesApi::class) + _base + .map { it.phase == RunPhase.Idle && it.breachGap?.reason == GapReason.Unreachable } + .distinctUntilChanged() + .mapLatest { stranded -> + if (!stranded) return@mapLatest + + connectivityRepository.observeInternet().awaitReconnect() + runHealthCheck(RunPhase.Refresh) + } + .launchIn(viewModelScope) + } + + fun onEvent(event: PasswordHealthUiEvent) { + when (event) { + PasswordHealthUiEvent.RunHealthCheck -> runHealthCheck(RunPhase.FirstLoad) + PasswordHealthUiEvent.RefreshHealthCheck -> runHealthCheck( + RunPhase.Refresh, + force = true + ) + + is PasswordHealthUiEvent.ItemClicked -> _eventChannel.trySend( + PasswordHealthEvent.OpenItem(event.itemId) + ) + + is PasswordHealthUiEvent.OnBreachCheckChanged -> viewModelScope.launch { + healthSettingsRepository.setBreachEnabled(event.enabled) + if (event.enabled) runHealthCheck(RunPhase.FirstLoad) + } + + is PasswordHealthUiEvent.OnNotificationChanged -> viewModelScope.launch { + setHealthNotifications(event.enabled) + // Takes the first snapshot for the reminder worker; the report is usually cached. + if (event.enabled) passwordHealth(force = false) + } + + is PasswordHealthUiEvent.FixClicked -> + _base.update { it.copy(fixFlow = FixFlow.Generating(event.itemId)) } + + is PasswordHealthUiEvent.PasswordGenerated -> _base.update { state -> + val target = state.fixFlow ?: return@update state + state.copy( + fixFlow = FixFlow.Pending(itemId = target.itemId, password = event.password), + ) + } + + PasswordHealthUiEvent.DismissGeneratePassword -> _base.update { + if (it.fixFlow is FixFlow.Generating) it.copy(fixFlow = null) else it + } + + is PasswordHealthUiEvent.OpenSite -> websiteHandler.openWebsite(event.url) + + PasswordHealthUiEvent.DiscardFix -> _base.update { it.copy(fixFlow = null) } + + PasswordHealthUiEvent.ConfirmPasswordChanged -> applyPendingFix() + } + } + + private fun applyPendingFix() { + val pending = _base.value.pendingFix ?: return + if (pending.applying) return + + viewModelScope.launch { + _base.update { + it.copy( + fixFlow = pending.copy(applying = true, error = null), + optimisticallyFixed = it.optimisticallyFixed + pending.itemId, + ) + } + + createNewOrUpdateLogin( + UpsertLogin.update(itemId = pending.itemId, password = set(pending.password)), + ).fold( + onSuccess = { + _base.update { it.copy(fixFlow = null) } + runHealthCheck(RunPhase.Refresh, restartInFlight = true) + }, + onFailure = { errors -> + val error = + if (errors.any { it is ItemUpsertError.CryptoError }) PasswordFixError.Locked + else PasswordFixError.Save + _base.update { + it.copy( + fixFlow = pending.copy(applying = false, error = error), + optimisticallyFixed = it.optimisticallyFixed - pending.itemId, + ) + } + }, + ) + } + } + + private fun runHealthCheck( + phase: RunPhase, + force: Boolean = false, + restartInFlight: Boolean = false, + ) { + if (run?.isActive == true) { + if (!restartInFlight) return + run?.cancel() + } + + run = viewModelScope.launch { + _base.update { it.copy(phase = phase) } + + val report = passwordHealth(force = force) + _base.update { state -> + report.fold( + onSuccess = { state.withReport(it) }, + onFailure = { state.withError(it) }, + ) + } + } + } +} + +private val RECONNECT_SETTLE = 1.seconds + +// Handovers flap the validated state, so a reconnect has to hold before it counts. A lookup can +// time out while the network still reports validated, so the connection has to drop and come back +// first, or the retry would loop. +@OptIn(FlowPreview::class) +private suspend fun Flow.awaitReconnect() { + dropWhile { it } + .debounce(RECONNECT_SETTLE) + .first { it } +} + +private fun PasswordHealthUiState.withReport(report: PasswordHealthReport) = copy( + phase = RunPhase.Idle, + error = null, + totalPasswordCount = report.totalPasswordsScanned, + reportedSections = report.toSections(), + checkGaps = report.gaps, + unreadable = report.unreadable, + optimisticallyFixed = emptySet(), +) + +private fun PasswordHealthUiState.withError(error: PasswordHealthReportError) = copy( + phase = RunPhase.Idle, + error = error, + totalPasswordCount = 0, + reportedSections = emptyList(), + checkGaps = emptyMap(), + unreadable = emptySet(), + optimisticallyFixed = emptySet(), +) diff --git a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/SegmentContainerColor.kt b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/SegmentContainerColor.kt new file mode 100644 index 000000000..7c567ba43 --- /dev/null +++ b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/SegmentContainerColor.kt @@ -0,0 +1,13 @@ +package de.davis.keygo.feature.password_health.presentation + +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.ReadOnlyComposable +import androidx.compose.ui.graphics.Color + + +internal val segmentContainerColor: Color + @Composable @ReadOnlyComposable get() = MaterialTheme.colorScheme.surfaceContainerHigh + +internal val openedSegmentContainerColor: Color + @Composable @ReadOnlyComposable get() = MaterialTheme.colorScheme.primaryContainer diff --git a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/component/BreachCheck.kt b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/component/BreachCheck.kt new file mode 100644 index 000000000..fa0b1b7e0 --- /dev/null +++ b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/component/BreachCheck.kt @@ -0,0 +1,95 @@ +package de.davis.keygo.feature.password_health.presentation.component + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.material3.ListItemDefaults +import androidx.compose.material3.ListItemShapes +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import de.davis.keygo.core.item.domain.alias.newItemId +import de.davis.keygo.core.ui.components.KeyGoSwitch +import de.davis.keygo.feature.password_health.R +import de.davis.keygo.feature.password_health.domain.model.CheckGap +import de.davis.keygo.feature.password_health.domain.model.CheckKind +import de.davis.keygo.feature.password_health.domain.model.GapReason +import de.davis.keygo.feature.password_health.presentation.model.PasswordHealthUiEvent +import de.davis.keygo.feature.password_health.presentation.model.PasswordHealthUiState +import de.davis.keygo.feature.password_health.presentation.segmentContainerColor + +@Composable +internal fun HealthSettings( + state: PasswordHealthUiState, + onEvent: (PasswordHealthUiEvent) -> Unit, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier, + verticalArrangement = Arrangement.spacedBy(ListItemDefaults.SegmentedGap), + ) { + BreachCheck( + state = state, + onChange = { onEvent(PasswordHealthUiEvent.OnBreachCheckChanged(it)) }, + shapes = ListItemDefaults.segmentedShapes(0, 2), + ) + + NotificationSwitch( + checked = state.notificationEnabled, + onChange = { onEvent(PasswordHealthUiEvent.OnNotificationChanged(it)) }, + shapes = ListItemDefaults.segmentedShapes(1, 2), + ) + } +} + +@Composable +private fun BreachCheck( + state: PasswordHealthUiState, + onChange: (Boolean) -> Unit, + shapes: ListItemShapes, +) { + KeyGoSwitch( + checked = state.breachCheckEnabled, + onCheckedChange = onChange, + supportingContent = { + Text(text = stringResource(R.string.breach_check_opt_in_description)) + }, + verticalAlignment = Alignment.CenterVertically, + colors = ListItemDefaults.segmentedColors(containerColor = segmentContainerColor), + shapes = shapes, + ) { + Text(text = stringResource(R.string.breach_check_enable)) + } +} + +@Preview +@Composable +private fun BreachCheckPreview() { + MaterialTheme { + HealthSettings( + state = PasswordHealthUiState(), + onEvent = {}, + ) + } +} + +@Preview +@Composable +private fun BreachCheckUnavailablePreview() { + MaterialTheme { + HealthSettings( + state = PasswordHealthUiState( + checkGaps = mapOf( + CheckKind.Breach to CheckGap( + reason = GapReason.Unreachable, + unchecked = setOf(newItemId(), newItemId(), newItemId()), + ), + ), + ), + onEvent = {} + ) + } +} diff --git a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/component/ItemHealthRow.kt b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/component/ItemHealthRow.kt new file mode 100644 index 000000000..38ba4949a --- /dev/null +++ b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/component/ItemHealthRow.kt @@ -0,0 +1,424 @@ +package de.davis.keygo.feature.password_health.presentation.component + +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.ContentTransform +import androidx.compose.animation.animateBounds +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.VectorConverter +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween +import androidx.compose.animation.expandHorizontally +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkHorizontally +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.OpenInNew +import androidx.compose.material.icons.filled.ArrowDropDown +import androidx.compose.material.icons.filled.AutoFixHigh +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExposedDropdownMenu +import androidx.compose.material3.ExposedDropdownMenuBox +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.ListItemDefaults +import androidx.compose.material3.ListItemShapes +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.MenuDefaults +import androidx.compose.material3.SegmentedListItem +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.movableContentOf +import androidx.compose.runtime.mutableIntStateOf +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.draw.clipToBounds +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.layout.LookaheadScope +import androidx.compose.ui.layout.layout +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Constraints +import androidx.compose.ui.unit.dp +import de.davis.keygo.core.item.domain.alias.newItemId +import de.davis.keygo.core.item.domain.model.PasswordScore +import de.davis.keygo.core.item.presentation.StrengthIndicator +import de.davis.keygo.feature.password_health.R +import de.davis.keygo.feature.password_health.domain.model.ItemHealth +import de.davis.keygo.feature.password_health.domain.model.ItemIssue +import de.davis.keygo.feature.password_health.domain.model.PasswordFixError +import de.davis.keygo.feature.password_health.presentation.model.FixFlow +import de.davis.keygo.feature.password_health.presentation.model.PasswordHealthUiEvent +import de.davis.keygo.feature.password_health.presentation.openedSegmentContainerColor +import de.davis.keygo.feature.password_health.presentation.segmentContainerColor + +@Composable +internal fun ItemHealthRow( + itemHealth: ItemHealth, + shapes: ListItemShapes, + pendingFix: FixFlow.Pending?, + isOpen: Boolean, + onEvent: (PasswordHealthUiEvent) -> Unit, + modifier: Modifier = Modifier, +) { + val breach = itemHealth.breach + val weak = itemHealth.weak + val username = itemHealth.username + + SegmentedListItem( + onClick = { onEvent(PasswordHealthUiEvent.ItemClicked(itemHealth.itemId)) }, + shapes = shapes, + modifier = modifier.fillMaxWidth(), + colors = ListItemDefaults.segmentedColors( + containerColor = if (isOpen) openedSegmentContainerColor else segmentContainerColor, + ), + supportingContent = if (pendingFix != null || username != null || weak != null) { + { + AnimatedContent( + targetState = pendingFix, + modifier = Modifier + .fillMaxWidth() + .animateHeight(), + contentKey = { it != null }, + transitionSpec = { + ContentTransform( + targetContentEnter = fadeIn( + tween(FadeInMillis, delayMillis = FadeOutMillis), + ), + initialContentExit = fadeOut(tween(FadeOutMillis)), + // A size transform animates width and height together, and the width + // it reports trails the width the row just measured the content with, + // so the content ends up wider than the box it is drawn in and gets + // clipped. Leave the width to the layout, which hands over exactly the + // space the trailing button has freed, and animate the height alone. + sizeTransform = null, + ) + }, + label = "fix-details", + ) { pending -> + if (pending != null) PendingFixActions( + urls = itemHealth.urls, + pending = pending, + onEvent = onEvent, + ) else FlaggedDetails(username = username, weak = weak) + } + } + } else null, + trailingContent = { + AnimatedVisibility( + visible = pendingFix == null, + enter = expandHorizontally(tween(CollapseMillis), expandFrom = Alignment.End) + + fadeIn(tween(FadeInMillis, delayMillis = FadeOutMillis)), + exit = shrinkHorizontally(tween(CollapseMillis), shrinkTowards = Alignment.End) + + fadeOut(tween(FadeOutMillis)), + ) { + IconButton( + onClick = { onEvent(PasswordHealthUiEvent.FixClicked(itemHealth.itemId)) }, + ) { + Icon(imageVector = Icons.Default.AutoFixHigh, contentDescription = null) + } + } + }, + verticalAlignment = Alignment.CenterVertically, + ) { + ItemHealthTitle( + title = itemHealth.title, + breach = breach, + inlineBreach = pendingFix != null, + ) + } +} + +@Composable +private fun ItemHealthTitle( + title: String, + breach: ItemIssue.Breached?, + inlineBreach: Boolean, + modifier: Modifier = Modifier, +) { + // The lookahead pass only pays off when there is a badge to move; skip it for every other row. + if (breach == null) return Text( + text = title, + modifier = modifier, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + + Box(modifier = modifier) { + LookaheadScope { + val badge = remember(breach) { + movableContentOf { + Text( + text = pluralStringResource( + R.plurals.needs_attention_found_in_breach, + breach.occurrences, + breach.occurrences, + ), + modifier = Modifier.animateBounds(this@LookaheadScope), + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.labelSmall, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + + Column( + modifier = Modifier.animateBounds(this@LookaheadScope), + verticalArrangement = Arrangement.spacedBy(TitleLineSpacing), + ) { + if (!inlineBreach) badge() + + Row( + horizontalArrangement = Arrangement.spacedBy(TitleBadgeSpacing), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = title, + modifier = Modifier + .weight(1f, fill = false) + .animateBounds(this@LookaheadScope), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + + if (inlineBreach) badge() + } + } + } + } +} + +@Composable +private fun Modifier.animateHeight(): Modifier { + var contentHeight by remember { mutableIntStateOf(UnmeasuredHeight) } + val animatedHeight = remember { Animatable(UnmeasuredHeight, Int.VectorConverter) } + + LaunchedEffect(contentHeight) { + if (contentHeight == UnmeasuredHeight) return@LaunchedEffect + + if (animatedHeight.value == UnmeasuredHeight) animatedHeight.snapTo(contentHeight) + else animatedHeight.animateTo(contentHeight, tween(ResizeMillis)) + } + + return clipToBounds() + .layout { measurable, constraints -> + val placeable = measurable.measure( + constraints.copy(minHeight = 0, maxHeight = Constraints.Infinity), + ) + val height = animatedHeight.value + + layout( + width = placeable.width, + height = if (height == UnmeasuredHeight) placeable.height else height, + ) { + placeable.place(0, 0) + } + } + .onSizeChanged { contentHeight = it.height } +} + +@Composable +private fun FlaggedDetails( + username: String?, + weak: ItemIssue.Weak?, + modifier: Modifier = Modifier, +) { + Column(modifier = modifier, verticalArrangement = Arrangement.spacedBy(8.dp)) { + if (username != null) Text( + text = username, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + + if (weak != null) StrengthIndicator(passwordScore = weak.score) + } +} + +@Composable +private fun PendingFixActions( + urls: List, + pending: FixFlow.Pending, + onEvent: (PasswordHealthUiEvent) -> Unit, + modifier: Modifier = Modifier, +) { + val enabled = !pending.applying + + Column(modifier = modifier, verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text(text = stringResource(R.string.fix_pending_instruction)) + + if (pending.error != null) Text( + text = pending.error.message, + color = MaterialTheme.colorScheme.error, + ) + + FlowRow( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + TextButton( + onClick = { onEvent(PasswordHealthUiEvent.DiscardFix) }, + enabled = enabled, + ) { + Text(text = stringResource(R.string.fix_cancel)) + } + + if (urls.isNotEmpty()) OpenSiteAction( + urls = urls, + enabled = enabled, + onOpen = { onEvent(PasswordHealthUiEvent.OpenSite(it)) }, + ) + + Button( + onClick = { onEvent(PasswordHealthUiEvent.ConfirmPasswordChanged) }, + enabled = enabled, + ) { + Text(text = stringResource(R.string.fix_confirm_changed)) + } + } + } +} + +@Composable +private fun OpenSiteAction( + urls: List, + enabled: Boolean, + onOpen: (String) -> Unit, +) { + var expanded by remember { mutableStateOf(false) } + val chevronRotation by animateFloatAsState(if (expanded) 180f else 0f, label = "chevron") + val single = urls.singleOrNull() + + ExposedDropdownMenuBox( + expanded = expanded, + onExpandedChange = { expanded = it }, + ) { + TextButton( + onClick = { if (single != null) onOpen(single) else expanded = true }, + enabled = enabled, + ) { + Icon( + imageVector = Icons.AutoMirrored.Filled.OpenInNew, + contentDescription = null, + modifier = Modifier.size(ButtonDefaults.IconSize), + ) + Spacer(modifier = Modifier.width(ButtonDefaults.IconSpacing)) + Text( + text = stringResource(R.string.fix_open_site), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + + if (single == null) { + Spacer(modifier = Modifier.width(ButtonDefaults.IconSpacing)) + Icon( + imageVector = Icons.Default.ArrowDropDown, + contentDescription = null, + modifier = Modifier + .size(ButtonDefaults.IconSize) + .graphicsLayer { rotationZ = chevronRotation }, + ) + } + } + + ExposedDropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false }, + containerColor = MenuDefaults.groupStandardContainerColor, + shape = MenuDefaults.standaloneGroupShape, + ) { + urls.forEachIndexed { index, url -> + DropdownMenuItem( + text = { Text(text = url) }, + onClick = { + expanded = false + onOpen(url) + }, + shape = MenuDefaults.itemShape(index, urls.size).shape, + ) + } + } + } +} + +private const val FadeOutMillis = 90 +private const val FadeInMillis = 220 +private const val CollapseMillis = 150 +private const val ResizeMillis = 250 +private const val UnmeasuredHeight = -1 + +private val TitleLineSpacing = 2.dp +private val TitleBadgeSpacing = 8.dp + +private val PasswordFixError.message: String + @Composable + get() = stringResource( + when (this) { + PasswordFixError.Locked -> R.string.fix_failed_locked + PasswordFixError.Save -> R.string.fix_failed_save + } + ) + +@Preview +@Composable +private fun ItemHealthRowPreview() { + val itemId = newItemId() + + MaterialTheme { + Surface { + Column(verticalArrangement = Arrangement.spacedBy(ListItemDefaults.SegmentedGap)) { + ItemHealthRow( + itemHealth = ItemHealth( + itemId = newItemId(), + title = "Old forum account", + username = "user@example.com", + issues = listOf(ItemIssue.Weak(score = PasswordScore.Weak)), + ), + shapes = ListItemDefaults.shapes(MaterialTheme.shapes.large), + pendingFix = null, + isOpen = false, + onEvent = {}, + ) + + ItemHealthRow( + itemHealth = ItemHealth( + itemId = itemId, + title = "GitHub", + username = "user@example.com", + issues = listOf(ItemIssue.Breached(occurrences = 4)), + urls = listOf("github.com", "gist.github.com"), + ), + shapes = ListItemDefaults.shapes(MaterialTheme.shapes.large), + pendingFix = FixFlow.Pending( + itemId = itemId, + password = "correct-horse-battery-staple", + ), + isOpen = true, + onEvent = {}, + ) + } + } + } +} diff --git a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/component/NeedsAttentionSection.kt b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/component/NeedsAttentionSection.kt new file mode 100644 index 000000000..d080d6db9 --- /dev/null +++ b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/component/NeedsAttentionSection.kt @@ -0,0 +1,262 @@ +package de.davis.keygo.feature.password_health.presentation.component + +import androidx.annotation.StringRes +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.material3.ListItemDefaults +import androidx.compose.material3.ListItemShapes +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.heading +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import de.davis.keygo.core.item.domain.alias.ItemId +import de.davis.keygo.core.item.domain.alias.newItemId +import de.davis.keygo.core.item.domain.model.PasswordScore +import de.davis.keygo.feature.password_health.R +import de.davis.keygo.feature.password_health.domain.model.FindingSeverity +import de.davis.keygo.feature.password_health.domain.model.HealthFinding +import de.davis.keygo.feature.password_health.domain.model.ItemHealth +import de.davis.keygo.feature.password_health.domain.model.ItemIssue +import de.davis.keygo.feature.password_health.domain.model.RelatedGroup +import de.davis.keygo.feature.password_health.domain.model.RelationType +import de.davis.keygo.feature.password_health.presentation.model.FixFlow +import de.davis.keygo.feature.password_health.presentation.model.HealthSection +import de.davis.keygo.feature.password_health.presentation.model.PasswordHealthUiEvent +import de.davis.keygo.feature.password_health.presentation.model.StandaloneCluster +import de.davis.keygo.feature.password_health.presentation.model.StandaloneIssue + +internal fun LazyListScope.needsAttentionSection( + sections: List, + pendingFix: FixFlow.Pending?, + openItemId: ItemId?, + onEvent: (PasswordHealthUiEvent) -> Unit, +) { + item(key = "needs-attention-title", contentType = ContentType.Title) { + Spacer(modifier = Modifier.height(28.dp)) + Text( + text = stringResource(R.string.needs_attention_title), + style = MaterialTheme.typography.titleMedium, + ) + Spacer(modifier = Modifier.height(12.dp)) + } + + sections.forEach { section -> + item( + key = "severity-${section.severity}", + contentType = ContentType.SeverityHeader + ) { + Text( + text = stringResource(section.severity.labelRes), + style = MaterialTheme.typography.labelMedium, + modifier = Modifier + .animateItem() + .semantics { heading() } + .padding(top = 12.dp, bottom = 4.dp), + ) + } + + section.groups.forEach { group -> + item(key = "group-${group.id}", contentType = ContentType.ClusterLabel) { + GroupLabel(group = group, modifier = Modifier.animateItem()) + } + + segmentedRows(group.orderedMembers, pendingFix, openItemId, onEvent) + } + + section.standaloneClusters.forEach { cluster -> + item( + key = "standalone-${section.severity}-${cluster.issue}", + contentType = ContentType.ClusterLabel, + ) { + StandaloneLabel(cluster = cluster, modifier = Modifier.animateItem()) + } + + segmentedRows(cluster.items, pendingFix, openItemId, onEvent) + } + } +} + +private fun LazyListScope.segmentedRows( + items: List, + pendingFix: FixFlow.Pending?, + openItemId: ItemId?, + onEvent: (PasswordHealthUiEvent) -> Unit, +) { + itemsIndexed( + items = items, + key = { _, item -> item.itemId }, + contentType = { _, _ -> ContentType.Row }, + ) { index, item -> + ItemHealthRow( + itemHealth = item, + shapes = segmentedShapesFor(index = index, count = items.size), + pendingFix = pendingFix?.takeIf { it.itemId == item.itemId }, + isOpen = item.itemId == openItemId, + onEvent = onEvent, + modifier = Modifier.animateItem(), + ) + } +} + +@Composable +private fun GroupLabel( + group: RelatedGroup, + modifier: Modifier = Modifier, +) { + val count = group.members.size + val label = when (group.dominantRelation) { + RelationType.Reused -> R.plurals.needs_attention_group_reused + RelationType.Similar -> R.plurals.needs_attention_group_similar + } + + ClusterLabel(text = pluralStringResource(label, count, count), modifier = modifier) +} + +@Composable +private fun StandaloneLabel( + cluster: StandaloneCluster, + modifier: Modifier = Modifier, +) { + val count = cluster.items.size + val label = when (cluster.issue) { + StandaloneIssue.Breached -> R.plurals.needs_attention_standalone_breached + StandaloneIssue.Weak -> R.plurals.needs_attention_standalone_weak + } + + ClusterLabel(text = pluralStringResource(label, count, count), modifier = modifier) +} + +@Composable +private fun ClusterLabel( + text: String, + modifier: Modifier = Modifier, +) { + Text( + text = text, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = modifier + .semantics { heading() } + .padding(start = 16.dp, top = 12.dp, bottom = 2.dp), + ) +} + +@Composable +private fun segmentedShapesFor(index: Int, count: Int): ListItemShapes = + if (count == 1) ListItemDefaults.shapes(MaterialTheme.shapes.large) + else ListItemDefaults.segmentedShapes(index, count) + +private val FindingSeverity.labelRes: Int + @StringRes + get() = when (this) { + FindingSeverity.Critical -> R.string.needs_attention_severity_critical + FindingSeverity.High -> R.string.needs_attention_severity_high + FindingSeverity.Medium -> R.string.needs_attention_severity_medium + } + +private enum class ContentType { + Title, + SeverityHeader, + ClusterLabel, + Row, +} + +@Preview +@Composable +private fun NeedsAttentionSectionPreview() { + val reused = List(3) { index -> + ItemHealth( + itemId = newItemId(), + title = listOf("Reddit", "Twitch", "Steam")[index], + username = "user@example.com", + issues = emptyList(), + ) + } + + MaterialTheme { + Surface( + modifier = Modifier.fillMaxSize() + ) { + LazyColumn( + modifier = Modifier.fillMaxSize(), + verticalArrangement = Arrangement.spacedBy(ListItemDefaults.SegmentedGap) + ) { + needsAttentionSection( + pendingFix = null, + openItemId = null, + onEvent = {}, + sections = listOf( + HealthSection( + severity = FindingSeverity.Critical, + groups = emptyList(), + standalone = listOf( + ItemHealth( + itemId = newItemId(), + title = "GitHub", + username = "user@example.com", + issues = listOf(ItemIssue.Breached(occurrences = 4)), + ), + ), + ), + HealthSection( + severity = FindingSeverity.High, + groups = listOf( + RelatedGroup( + id = reused.first().itemId, + members = reused.toSet(), + relations = setOf( + HealthFinding.Relation( + relatedItemIds = reused.mapTo(mutableSetOf()) { it.itemId }, + type = RelationType.Reused, + ), + ), + ), + ), + standalone = listOf( + ItemHealth( + itemId = newItemId(), + title = "Bank", + username = "user@example.com", + issues = listOf(ItemIssue.Weak(score = PasswordScore.Weak)), + ), + ), + ), + HealthSection( + severity = FindingSeverity.Medium, + groups = emptyList(), + standalone = listOf( + ItemHealth( + itemId = newItemId(), + title = "Old forum account", + username = null, + issues = listOf(ItemIssue.Weak(score = PasswordScore.Weak)), + ), + ItemHealth( + itemId = newItemId(), + title = "Router admin", + username = "admin", + issues = listOf( + ItemIssue.Weak(score = PasswordScore.Ridiculous), + ), + ), + ), + ), + ), + ) + } + } + } +} diff --git a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/component/NotificationSwitch.kt b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/component/NotificationSwitch.kt new file mode 100644 index 000000000..e7ba377fc --- /dev/null +++ b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/component/NotificationSwitch.kt @@ -0,0 +1,184 @@ +package de.davis.keygo.feature.password_health.presentation.component + +import android.Manifest +import android.content.Intent +import android.net.Uri +import android.os.Build +import android.provider.Settings +import android.util.Log +import androidx.annotation.RequiresApi +import androidx.annotation.StringRes +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Notifications +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.Icon +import androidx.compose.material3.ListItemDefaults +import androidx.compose.material3.ListItemShapes +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Text +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.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import com.google.accompanist.permissions.ExperimentalPermissionsApi +import com.google.accompanist.permissions.isGranted +import com.google.accompanist.permissions.rememberPermissionState +import com.google.accompanist.permissions.shouldShowRationale +import de.davis.keygo.core.security.presentation.rememberHandoffStarter +import de.davis.keygo.core.ui.components.KeyGoSwitch +import de.davis.keygo.core.util.onFailure +import de.davis.keygo.feature.password_health.R +import de.davis.keygo.feature.password_health.presentation.segmentContainerColor + +private const val TAG = "NotificationSwitch" + +private enum class PermissionDialog { Rationale, OpenSettings } + +@Composable +internal fun NotificationSwitch( + checked: Boolean, + onChange: (Boolean) -> Unit, + shapes: ListItemShapes, +) { + // Below API 33 the permission does not exist, so Accompanist would always report it denied. + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) + PermissionGatedSwitch(checked, onChange, shapes) + else NotificationSwitchContent(checked, onChange, shapes) +} + +@OptIn(ExperimentalPermissionsApi::class) +@RequiresApi(Build.VERSION_CODES.TIRAMISU) +@Composable +private fun PermissionGatedSwitch( + checked: Boolean, + onChange: (Boolean) -> Unit, + shapes: ListItemShapes, +) { + var dialog by remember { mutableStateOf(null) } + var enableWhenGranted by remember { mutableStateOf(false) } + var asked by rememberSaveable { mutableStateOf(false) } + val permission = rememberPermissionState(Manifest.permission.POST_NOTIFICATIONS) { granted -> + if (granted) onChange(true) + asked = true + } + + // Accompanist re-reads the status on resume, which is how a grant made in settings shows up. + LaunchedEffect(permission.status.isGranted) { + if (permission.status.isGranted && enableWhenGranted) onChange(true) + enableWhenGranted = false + } + + NotificationSwitchContent( + checked = checked && permission.status.isGranted, + onChange = { enabled -> + when { + !enabled || permission.status.isGranted -> onChange(enabled) + permission.status.shouldShowRationale -> dialog = PermissionDialog.Rationale + asked -> dialog = PermissionDialog.OpenSettings + else -> permission.launchPermissionRequest() + } + }, + shapes = shapes, + ) + + when (dialog) { + PermissionDialog.Rationale -> PermissionDialog( + text = R.string.notification_permission_rationale, + confirm = R.string.notification_permission_grant, + onConfirm = { + dialog = null + asked = true + permission.launchPermissionRequest() + }, + onDismiss = { dialog = null }, + ) + + PermissionDialog.OpenSettings -> OpenSettingsDialog( + onOpen = { + dialog = null + enableWhenGranted = true + }, + onDismiss = { dialog = null }, + ) + + null -> Unit + } +} + +@Composable +private fun OpenSettingsDialog( + onOpen: () -> Unit, + onDismiss: () -> Unit, +) { + val context = LocalContext.current + val openSystemScreen = rememberHandoffStarter() + + PermissionDialog( + text = R.string.notification_permission_denied_permanently, + confirm = R.string.notification_permission_open_settings, + onConfirm = { + onOpen() + openSystemScreen.launch( + Intent( + Settings.ACTION_APPLICATION_DETAILS_SETTINGS, + Uri.fromParts("package", context.packageName, null), + ), + ).onFailure { + Log.w(TAG, "No activity found to handle the app details settings", it) + } + }, + onDismiss = onDismiss, + ) +} + +@Composable +private fun PermissionDialog( + @StringRes text: Int, + @StringRes confirm: Int, + onConfirm: () -> Unit, + onDismiss: () -> Unit, +) { + AlertDialog( + onDismissRequest = onDismiss, + confirmButton = { + Button(onClick = onConfirm) { + Text(text = stringResource(confirm)) + } + }, + dismissButton = { + OutlinedButton(onClick = onDismiss) { + Text(text = stringResource(R.string.notification_permission_cancel)) + } + }, + title = { Text(text = stringResource(R.string.notification_permission_title)) }, + text = { Text(text = stringResource(text)) }, + icon = { Icon(imageVector = Icons.Default.Notifications, contentDescription = null) }, + ) +} + +@Composable +private fun NotificationSwitchContent( + checked: Boolean, + onChange: (Boolean) -> Unit, + shapes: ListItemShapes, +) { + KeyGoSwitch( + checked = checked, + onCheckedChange = onChange, + supportingContent = { + Text(text = stringResource(R.string.health_notifications_description)) + }, + verticalAlignment = Alignment.CenterVertically, + colors = ListItemDefaults.segmentedColors(containerColor = segmentContainerColor), + shapes = shapes, + ) { + Text(text = stringResource(R.string.health_notifications_enable)) + } +} diff --git a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/component/PasswordHealthDetailPlaceholder.kt b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/component/PasswordHealthDetailPlaceholder.kt new file mode 100644 index 000000000..2ea1874a4 --- /dev/null +++ b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/component/PasswordHealthDetailPlaceholder.kt @@ -0,0 +1,42 @@ +package de.davis.keygo.feature.password_health.presentation.component + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import de.davis.keygo.feature.password_health.R + +@Composable +fun PasswordHealthDetailPlaceholder(modifier: Modifier = Modifier) { + Box( + modifier = modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + Text( + text = stringResource(R.string.password_health_detail_placeholder), + modifier = Modifier.padding(horizontal = 32.dp), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + ) + } +} + +@Preview +@Composable +private fun PasswordHealthDetailPlaceholderPreview() { + MaterialTheme { + Surface(modifier = Modifier.fillMaxSize()) { + PasswordHealthDetailPlaceholder() + } + } +} diff --git a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/component/PasswordHealthStatus.kt b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/component/PasswordHealthStatus.kt new file mode 100644 index 000000000..3d7ea97a4 --- /dev/null +++ b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/component/PasswordHealthStatus.kt @@ -0,0 +1,204 @@ +package de.davis.keygo.feature.password_health.presentation.component + +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.animateContentSize +import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkVertically +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.CircularWavyProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.contentColorFor +import androidx.compose.runtime.Composable +import androidx.compose.runtime.ReadOnlyComposable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import de.davis.keygo.core.item.domain.alias.newItemId +import de.davis.keygo.core.ui.components.KeyGoCard +import de.davis.keygo.core.ui.components.KeyGoCardProperties +import de.davis.keygo.feature.password_health.R +import de.davis.keygo.feature.password_health.domain.model.CheckGap +import de.davis.keygo.feature.password_health.domain.model.GapReason +import de.davis.keygo.feature.password_health.domain.model.PasswordHealthReportError +import de.davis.keygo.feature.password_health.presentation.model.PasswordHealthStatus +import de.davis.keygo.feature.password_health.presentation.model.PasswordHealthUiState +import de.davis.keygo.feature.password_health.presentation.model.RunPhase +import de.davis.keygo.feature.password_health.presentation.model.coverageNote +import de.davis.keygo.feature.password_health.presentation.model.detailLine +import de.davis.keygo.feature.password_health.presentation.model.icon +import de.davis.keygo.feature.password_health.presentation.model.toneColor +import de.davis.keygo.feature.password_health.presentation.model.verdict + +@Composable +internal fun PasswordHealthStatus( + state: PasswordHealthUiState, + modifier: Modifier = Modifier +) { + val containerColorTarget = state.toneColor() + val containerColor by animateColorAsState( + targetValue = containerColorTarget, + label = "containerColor" + ) + val contentColor by animateColorAsState( + targetValue = contentColorFor(containerColorTarget), + label = "contentColor" + ) + val iconColor by animateColorAsState( + targetValue = if (state.isAccented) MaterialTheme.colorScheme.tertiary + else contentColorFor(containerColorTarget), + label = "iconColor" + ) + + KeyGoCard( + title = { + Text(text = stringResource(R.string.password_health_overview_label)) + }, + modifier = modifier + .fillMaxWidth() + .animateContentSize(), + properties = KeyGoCardProperties.elevated( + containerColor = containerColor, + contentColor = contentColor + ), + leadingItem = { + AnimatedContent(state.isFirstLoad) { + when (it) { + true -> CircularWavyProgressIndicator( + modifier = Modifier.size(40.dp) + ) + + false -> Icon( + imageVector = state.status.icon(), + contentDescription = null, + tint = iconColor, + ) + } + } + } + ) { + Text(text = state.verdict()) + + AnimatedVisibility( + visible = !state.isFirstLoad && state.status == PasswordHealthStatus.NEEDS_ATTENTION, + enter = fadeIn() + expandVertically(), + exit = fadeOut() + shrinkVertically(), + label = "breakdownVisibility" + ) { + SeverityBreakdownBar( + breakdown = state.breakdown, + containerColor = containerColorTarget, + accentColor = + if (state.isAccented) MaterialTheme.colorScheme.tertiary + else containerColorTarget, + modifier = Modifier.padding(vertical = 8.dp), + ) + } + + SupportingLine(text = state.detailLine(), label = "detailLineVisibility") + SupportingLine(text = state.coverageNote(), label = "coverageNoteVisibility") + SupportingLine(text = state.breachGap?.message(), label = "breachGapVisibility") + } +} + +@Composable +@ReadOnlyComposable +private fun CheckGap.message(): String? { + val plural = when (reason) { + // The check was disabled by the user, so there is nothing to warn about. + GapReason.Disabled -> return null + + GapReason.Unreachable -> R.plurals.breach_check_unreachable + GapReason.Failed -> R.plurals.breach_check_failed + } + + return pluralStringResource(plural, unchecked.size, unchecked.size) +} + +@Composable +private fun SupportingLine(text: String?, label: String) { + AnimatedVisibility( + visible = text != null, + enter = fadeIn() + expandVertically(), + exit = fadeOut() + shrinkVertically(), + label = label + ) { + if (text != null) { + Text( + text = text, + style = MaterialTheme.typography.bodySmall + ) + } + } +} + +@Preview +@Composable +private fun AllGoodPreview() { + MaterialTheme { + Surface( + modifier = Modifier.fillMaxSize() + ) { + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + PasswordHealthStatus(state = PasswordHealthUiState()) + PasswordHealthStatus( + state = PasswordHealthUiState( + phase = RunPhase.FirstLoad + ) + ) + PasswordHealthStatus( + state = PasswordHealthUiState( + totalPasswordCount = 12, + ) + ) + PasswordHealthStatus( + state = PasswordHealthUiState( + totalPasswordCount = 12, + ), + ) + } + } + } +} + +@Preview +@Composable +private fun UnavailablePreview() { + MaterialTheme { + Surface( + modifier = Modifier.fillMaxSize() + ) { + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + PasswordHealthStatus( + state = PasswordHealthUiState( + error = PasswordHealthReportError.Unreadable, + ), + ) + PasswordHealthStatus( + state = PasswordHealthUiState( + totalPasswordCount = 9, + unreadable = setOf(newItemId(), newItemId(), newItemId()), + ), + ) + } + } + } +} diff --git a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/component/SeverityBreakdownBar.kt b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/component/SeverityBreakdownBar.kt new file mode 100644 index 000000000..cd8425db3 --- /dev/null +++ b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/component/SeverityBreakdownBar.kt @@ -0,0 +1,210 @@ +package de.davis.keygo.feature.password_health.presentation.component + +import androidx.annotation.StringRes +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.darkColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.drawscope.DrawScope +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.clearAndSetSemantics +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.unit.dp +import de.davis.keygo.feature.password_health.R +import de.davis.keygo.feature.password_health.presentation.model.SeverityBreakdown +import kotlin.math.min + +@Composable +internal fun SeverityBreakdownBar( + breakdown: SeverityBreakdown, + containerColor: Color, + accentColor: Color, + modifier: Modifier = Modifier, +) { + val ramp = remember(containerColor, accentColor) { severityRamp(containerColor, accentColor) } + val critical by animateColorAsState(ramp.critical, label = "criticalColor") + val high by animateColorAsState(ramp.high, label = "highColor") + val medium by animateColorAsState(ramp.medium, label = "mediumColor") + val segments = listOf( + Segment(breakdown.critical, critical, R.string.breakdown_critical), + Segment(breakdown.high, high, R.string.breakdown_high), + Segment(breakdown.medium, medium, R.string.breakdown_medium), + Segment( + breakdown.clean, + LocalContentColor.current.copy(alpha = TrackAlpha), + R.string.breakdown_clean, + ), + ) + val weights = segments.map { + animateFloatAsState(targetValue = it.count.toFloat(), label = "segmentWeight") + } + + Column( + modifier = modifier, + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + Canvas( + modifier = Modifier + .fillMaxWidth() + .height(10.dp) + .clearAndSetSemantics {}, + ) { + drawSegments( + colors = segments.map { it.color }, + weights = weights.map { it.value }, + gap = 4.dp.toPx(), + ) + } + + FlowRow( + horizontalArrangement = Arrangement.spacedBy(16.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + segments.filter { it.count > 0 }.forEach { + LegendEntry(it) + } + } + } +} + +private const val TrackAlpha = 0.14f + +private data class Segment( + val count: Int, + val color: Color, + @param:StringRes val label: Int, +) + +// A segment's minimum width (a dot) and trailing gap scale with its weight up to 1, so a single +// finding among hundreds stays visible and segments grow in and out without jumping. +private fun DrawScope.drawSegments(colors: List, weights: List, gap: Float) { + val total = weights.sum() + if (total <= 0f) return + + val presence = weights.map { it.coerceIn(0f, 1f) } + val presenceSum = presence.sum() + val available = size.width - gap * (presenceSum - presence.last { it > 0f }) + val free = available - presenceSum * size.height + + var x = 0f + weights.forEachIndexed { i, weight -> + if (weight <= 0f) return@forEachIndexed + + val width = + if (free >= 0f) presence[i] * size.height + free * weight / total + else available * weight / total + val left = if (layoutDirection == LayoutDirection.Rtl) size.width - x - width else x + + drawRoundRect( + color = colors[i], + topLeft = Offset(left, 0f), + size = Size(width, size.height), + cornerRadius = CornerRadius(min(width, size.height) / 2), + ) + x += width + gap * presence[i] + } +} + +@Composable +private fun LegendEntry(segment: Segment) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + Box( + modifier = Modifier + .size(8.dp) + .background(segment.color, CircleShape), + ) + Text( + text = segment.legendText(), + style = MaterialTheme.typography.bodySmall, + ) + } +} + +@Composable +private fun Segment.legendText(): AnnotatedString { + val text = stringResource(label, count) + + // stringResource does format the same way by taking the first locale + val number = "%d".format(LocalConfiguration.current.locales[0], count) + val start = text.indexOf(number) + + return buildAnnotatedString { + append(text) + if (start >= 0) addStyle( + style = SpanStyle(fontWeight = FontWeight.SemiBold), + start = start, + end = start + number.length, + ) + } +} + +@Composable +private fun BreakdownPreviewCards() { + Column { + listOf( + MaterialTheme.colorScheme.errorContainer, + MaterialTheme.colorScheme.tertiaryContainer, + MaterialTheme.colorScheme.surfaceContainerHigh, + ).forEach { container -> + Surface(color = container) { + SeverityBreakdownBar( + breakdown = SeverityBreakdown(critical = 1, high = 5, medium = 3, clean = 120), + containerColor = container, + accentColor = + if (container == MaterialTheme.colorScheme.surfaceContainerHigh) MaterialTheme.colorScheme.tertiary + else container, + modifier = Modifier.padding(16.dp), + ) + } + } + } +} + +@Preview +@Composable +private fun SeverityBreakdownBarPreview() { + MaterialTheme { + BreakdownPreviewCards() + } +} + +@Preview +@Composable +private fun SeverityBreakdownBarDarkPreview() { + MaterialTheme(colorScheme = darkColorScheme()) { + BreakdownPreviewCards() + } +} diff --git a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/component/SeverityRamp.kt b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/component/SeverityRamp.kt new file mode 100644 index 000000000..d355f2a93 --- /dev/null +++ b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/component/SeverityRamp.kt @@ -0,0 +1,95 @@ +package de.davis.keygo.feature.password_health.presentation.component + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.colorspace.ColorSpaces +import androidx.compose.ui.graphics.luminance +import kotlin.math.abs +import kotlin.math.atan2 +import kotlin.math.cbrt +import kotlin.math.cos +import kotlin.math.hypot +import kotlin.math.max +import kotlin.math.min +import kotlin.math.sin + +internal data class SeverityRamp(val critical: Color, val high: Color, val medium: Color) + +internal const val CriticalContrast = 4.5f +internal const val HighContrast = 3.75f +internal const val MediumContrast = 3f + +private const val MaxChroma = 72f +private const val HighChroma = 0.75f +private const val MediumChroma = 0.5f +private const val ToneStep = 0.5f + +// Every step keeps the accent's hue (the card's own by default), so the ramp matches any dynamic +// scheme. Severity is carried mostly by vividness: critical is the most saturated the hue allows, +// the lower steps fade towards it. Contrast alone cannot do this, because the far end of a dark card +// is near white, where no hue survives. Neutral accents get a muted cap so a faint tint does not +// turn neon. +internal fun severityRamp(container: Color, accent: Color = container): SeverityRamp { + val lab = accent.convert(ColorSpaces.CieLab) + val hue = atan2(lab.blue, lab.green) + val cap = min(hypot(lab.green, lab.blue) * 6f, MaxChroma) + + val background = container.luminance() + 0.05f + val darker = background / 0.05f >= 1.05f / background + + // Gamut mapping and 8-bit rounding can cost a little contrast, so the result is measured and + // pushed further out until it holds. + fun step(ratio: Float, chroma: Float): Color { + val y = if (darker) background / ratio - 0.05f else ratio * background - 0.05f + var tone = yToTone(y.coerceIn(0f, 1f)) + var color = labColor(tone, chroma, hue) + while (contrast(color.luminance() + 0.05f, background) < ratio && tone in 0f..100f) { + tone += if (darker) -ToneStep else ToneStep + color = labColor(tone.coerceIn(0f, 100f), chroma, hue) + } + return color + } + + val critical = step(CriticalContrast, cap) + val criticalChroma = critical.chroma() + + return SeverityRamp( + critical = critical, + high = step(HighContrast, criticalChroma * HighChroma), + medium = step(MediumContrast, criticalChroma * MediumChroma), + ) +} + +internal fun Color.chroma(): Float { + val lab = convert(ColorSpaces.CieLab) + return hypot(lab.green, lab.blue) +} + +private fun contrast(a: Float, b: Float) = max(a, b) / min(a, b) + +private fun yToTone(y: Float): Float = + if (y > 216f / 24389f) 116f * cbrt(y) - 16f + else y * 24389f / 27f + +// Lowers chroma until the color fits sRGB, so clamping never shifts the tone the contrast relies on. +private fun labColor(tone: Float, chroma: Float, hue: Float): Color { + fun at(c: Float) = Color(tone, c * cos(hue), c * sin(hue), colorSpace = ColorSpaces.CieLab) + + val full = at(chroma) + if (full.fitsSrgb()) return full.convert(ColorSpaces.Srgb) + + var low = 0f + var high = chroma + repeat(12) { + val mid = (low + high) / 2 + if (at(mid).fitsSrgb()) low = mid + else high = mid + } + return at(low).convert(ColorSpaces.Srgb) +} + +private fun Color.fitsSrgb(): Boolean { + val back = convert(ColorSpaces.Srgb).convert(ColorSpaces.CieLab) + return abs(back.red - red) < 0.5f && + abs(back.green - green) < 0.5f && + abs(back.blue - blue) < 0.5f +} diff --git a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/model/FixFlow.kt b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/model/FixFlow.kt new file mode 100644 index 000000000..d2d73fd62 --- /dev/null +++ b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/model/FixFlow.kt @@ -0,0 +1,19 @@ +package de.davis.keygo.feature.password_health.presentation.model + +import androidx.compose.runtime.Stable +import de.davis.keygo.core.item.domain.alias.ItemId +import de.davis.keygo.feature.password_health.domain.model.PasswordFixError + +@Stable +internal sealed interface FixFlow { + val itemId: ItemId + + data class Generating(override val itemId: ItemId) : FixFlow + + data class Pending( + override val itemId: ItemId, + val password: String, + val applying: Boolean = false, + val error: PasswordFixError? = null, + ) : FixFlow +} diff --git a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/model/PasswordHealthEvent.kt b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/model/PasswordHealthEvent.kt new file mode 100644 index 000000000..1f82ee1af --- /dev/null +++ b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/model/PasswordHealthEvent.kt @@ -0,0 +1,8 @@ +package de.davis.keygo.feature.password_health.presentation.model + +import de.davis.keygo.core.item.domain.alias.ItemId + +internal sealed interface PasswordHealthEvent { + + data class OpenItem(val itemId: ItemId) : PasswordHealthEvent +} diff --git a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/model/PasswordHealthUiEvent.kt b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/model/PasswordHealthUiEvent.kt new file mode 100644 index 000000000..b77c14655 --- /dev/null +++ b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/model/PasswordHealthUiEvent.kt @@ -0,0 +1,23 @@ +package de.davis.keygo.feature.password_health.presentation.model + +import de.davis.keygo.core.item.domain.alias.ItemId + +internal sealed interface PasswordHealthUiEvent { + data object RunHealthCheck : PasswordHealthUiEvent + data object RefreshHealthCheck : PasswordHealthUiEvent + + data class ItemClicked(val itemId: ItemId) : PasswordHealthUiEvent + + data class OnBreachCheckChanged(val enabled: Boolean) : PasswordHealthUiEvent + data class OnNotificationChanged(val enabled: Boolean) : PasswordHealthUiEvent + + data class FixClicked(val itemId: ItemId) : PasswordHealthUiEvent + data class PasswordGenerated(val password: String) : PasswordHealthUiEvent + data object DismissGeneratePassword : PasswordHealthUiEvent + + data class OpenSite(val url: String) : PasswordHealthUiEvent + + data object ConfirmPasswordChanged : PasswordHealthUiEvent + + data object DiscardFix : PasswordHealthUiEvent +} diff --git a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/model/PasswordHealthUiState.kt b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/model/PasswordHealthUiState.kt new file mode 100644 index 000000000..2a1b919d1 --- /dev/null +++ b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/model/PasswordHealthUiState.kt @@ -0,0 +1,321 @@ +package de.davis.keygo.feature.password_health.presentation.model + +import androidx.annotation.StringRes +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.GppBad +import androidx.compose.material.icons.filled.GppGood +import androidx.compose.material.icons.filled.GppMaybe +import androidx.compose.material.icons.filled.HealthAndSafety +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.ReadOnlyComposable +import androidx.compose.runtime.Stable +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringResource +import de.davis.keygo.core.item.domain.alias.ItemId +import de.davis.keygo.core.util.domain.comparator.NaturalOrderComparator +import de.davis.keygo.feature.password_health.R +import de.davis.keygo.feature.password_health.domain.model.CheckGap +import de.davis.keygo.feature.password_health.domain.model.CheckKind +import de.davis.keygo.feature.password_health.domain.model.FindingSeverity +import de.davis.keygo.feature.password_health.domain.model.ItemHealth +import de.davis.keygo.feature.password_health.domain.model.ItemIssue +import de.davis.keygo.feature.password_health.domain.model.PasswordHealthReport +import de.davis.keygo.feature.password_health.domain.model.PasswordHealthReportError +import de.davis.keygo.feature.password_health.domain.model.RelatedGroup +import de.davis.keygo.feature.password_health.domain.model.RelationType + +internal enum class RunPhase { + Idle, + FirstLoad, + Refresh, +} + +internal enum class PasswordHealthStatus { + NO_DATA, + ALL_GOOD, + NEEDS_ATTENTION, + FAILED +} + +data class HealthSummary( + val needsAttention: Int, + val weak: Int, + val breached: Int, + val reused: Int, + val similar: Int, +) + +internal fun List.summary(): HealthSummary { + val groups = fold(mutableListOf()) { acc, section -> + acc.addAll(section.groups) + acc + } + val standalone = fold(mutableListOf()) { acc, section -> + acc.addAll(section.standalone) + acc + } + + + val flagged = groups.flatMap { it.members } + standalone + + fun countWith(predicate: (ItemIssue) -> Boolean) = + flagged.count { item -> item.issues.any(predicate) } + + fun countRelated(type: RelationType) = groups + .flatMap { group -> group.relations.filter { it.type == type } } + .flatMapTo(mutableSetOf()) { it.relatedItemIds } + .size + + return HealthSummary( + needsAttention = flagged.size, + weak = countWith { it is ItemIssue.Weak }, + breached = countWith { it is ItemIssue.Breached }, + reused = countRelated(RelationType.Reused), + similar = countRelated(RelationType.Similar), + ) +} + +internal data class SeverityBreakdown( + val critical: Int, + val high: Int, + val medium: Int, + val clean: Int, +) + +internal fun List.breakdown(checked: Int): SeverityBreakdown { + val worst = mutableMapOf() + fun flag(id: ItemId, severity: FindingSeverity?) { + if (severity != null) worst.merge(id, severity, ::maxOf) + } + + forEach { section -> + section.standalone.forEach { flag(it.itemId, it.maxSeverity) } + section.groups.forEach { group -> + val memberIds = group.members.mapTo(mutableSetOf()) { it.itemId } + group.members.forEach { flag(it.itemId, it.maxSeverity) } + group.relations.forEach { relation -> + relation.relatedItemIds.forEach { + if (it in memberIds) flag(it, relation.type.severity) + } + } + } + } + + val counts = worst.values.groupingBy { it }.eachCount() + return SeverityBreakdown( + critical = counts[FindingSeverity.Critical] ?: 0, + high = counts[FindingSeverity.High] ?: 0, + medium = counts[FindingSeverity.Medium] ?: 0, + clean = (checked - worst.size).coerceAtLeast(0), + ) +} + +internal data class HealthSection( + val severity: FindingSeverity, + val groups: List, + val standalone: List +) { + val standaloneClusters: List = standalone + .groupBy { if (it.breach != null) StandaloneIssue.Breached else StandaloneIssue.Weak } + .let { byIssue -> + StandaloneIssue.entries.mapNotNull { issue -> + byIssue[issue]?.let { StandaloneCluster(issue, it) } + } + } +} + +internal enum class StandaloneIssue { + Breached, + Weak, +} + +internal data class StandaloneCluster( + val issue: StandaloneIssue, + val items: List, +) + +internal fun PasswordHealthReport.toSections(): List { + val groupsBySeverity = groups.groupBy { it.maxSeverity } + val standaloneBySeverity = standalone.groupBy { requireNotNull(it.maxSeverity) } + + return FindingSeverity.entries.sortedDescending().mapNotNull { severity -> + val g = groupsBySeverity[severity].orEmpty().sortedWith(GroupOrder) + val s = standaloneBySeverity[severity].orEmpty().sortedWith(StandaloneOrder) + if (g.isEmpty() && s.isEmpty()) null else HealthSection(severity, g, s) + } +} + +private val GroupOrder = compareByDescending { it.members.size } + .thenBy(NaturalOrderComparator) { it.orderedMembers.first().title } + +private val StandaloneOrder = compareByDescending { it.maxSeverity } + .thenBy(NaturalOrderComparator) { it.title } + +internal fun List.withoutFixed(fixed: Set): List { + if (fixed.isEmpty()) return this + + return mapNotNull { section -> + val groups = mutableListOf() + val unrelated = mutableListOf() + + section.groups.forEach { group -> + val members = group.members.filterNot { it.itemId in fixed } + if (members.size >= MinimumGroupSize) groups += group.copy(members = members.toSet()) + else unrelated += members.filter { it.issues.isNotEmpty() } + } + + val standalone = (section.standalone.filterNot { it.itemId in fixed } + unrelated) + .sortedWith(StandaloneOrder) + + if (groups.isEmpty() && standalone.isEmpty()) null + else section.copy(groups = groups.sortedWith(GroupOrder), standalone = standalone) + } +} + +private const val MinimumGroupSize = 2 + +@Stable +internal data class PasswordHealthUiState( + val phase: RunPhase = RunPhase.Idle, + val breachCheckEnabled: Boolean = false, + val notificationEnabled: Boolean = false, + val totalPasswordCount: Int = 0, + val reportedSections: List = emptyList(), + val checkGaps: Map = emptyMap(), + val unreadable: Set = emptySet(), + val error: PasswordHealthReportError? = null, + val fixFlow: FixFlow? = null, + val optimisticallyFixed: Set = emptySet(), +) { + val isFirstLoad = phase == RunPhase.FirstLoad + val isRefreshing = phase == RunPhase.Refresh + + val generatingFix = fixFlow as? FixFlow.Generating + val pendingFix = fixFlow as? FixFlow.Pending + + val healthSections = reportedSections.withoutFixed(optimisticallyFixed) + + val summary by lazy { healthSections.summary() } + + val breakdown by lazy { healthSections.breakdown(totalPasswordCount - unreadable.size) } + + val worstSeverity: FindingSeverity? = healthSections.firstOrNull()?.severity + + val breachGap: CheckGap? = checkGaps[CheckKind.Breach] + + val status = when { + // An empty vault is not a failure, it just has nothing to say yet. + error == PasswordHealthReportError.NoPasswords -> PasswordHealthStatus.NO_DATA + error != null -> PasswordHealthStatus.FAILED + totalPasswordCount == 0 -> PasswordHealthStatus.NO_DATA + healthSections.isNotEmpty() -> PasswordHealthStatus.NEEDS_ATTENTION + else -> PasswordHealthStatus.ALL_GOOD + } + + // Medium findings keep a neutral card and carry their tone in an accent, so the card reads + // calmer than a high one without looking disabled. + val isAccented = !isFirstLoad && + status == PasswordHealthStatus.NEEDS_ATTENTION && + worstSeverity == FindingSeverity.Medium +} + +@Composable +@ReadOnlyComposable +internal fun PasswordHealthUiState.toneColor(): Color { + if (isFirstLoad) return MaterialTheme.colorScheme.surfaceContainerHigh + return when (status) { + PasswordHealthStatus.NO_DATA -> MaterialTheme.colorScheme.surfaceContainerHigh + PasswordHealthStatus.ALL_GOOD -> MaterialTheme.colorScheme.secondaryContainer + PasswordHealthStatus.NEEDS_ATTENTION -> when (worstSeverity) { + FindingSeverity.Critical, null -> MaterialTheme.colorScheme.errorContainer + FindingSeverity.High -> MaterialTheme.colorScheme.tertiaryContainer + FindingSeverity.Medium -> MaterialTheme.colorScheme.surfaceContainerHigh + } + + PasswordHealthStatus.FAILED -> MaterialTheme.colorScheme.errorContainer + } +} + +@Composable +@ReadOnlyComposable +internal fun PasswordHealthStatus.icon() = when (this) { + PasswordHealthStatus.NO_DATA -> Icons.Default.HealthAndSafety + PasswordHealthStatus.ALL_GOOD -> Icons.Default.GppGood + PasswordHealthStatus.NEEDS_ATTENTION -> Icons.Default.GppBad + PasswordHealthStatus.FAILED -> Icons.Default.GppMaybe +} + +@Composable +@ReadOnlyComposable +internal fun PasswordHealthUiState.verdict(): String { + if (isFirstLoad) return stringResource(R.string.checking_passwords) + return when (status) { + PasswordHealthStatus.NO_DATA -> stringResource(R.string.password_health_no_data) + + PasswordHealthStatus.ALL_GOOD -> + if (checkGaps.isEmpty() && unreadable.isEmpty()) + stringResource(R.string.password_health_all_good) + else stringResource(R.string.password_health_checked_all_good) + + PasswordHealthStatus.NEEDS_ATTENTION -> pluralStringResource( + R.plurals.password_health_needs_attention, + summary.needsAttention, + summary.needsAttention + ) + + PasswordHealthStatus.FAILED -> error.failureMessage() + } +} + +@Composable +@ReadOnlyComposable +internal fun PasswordHealthUiState.detailLine(): String? { + if (isFirstLoad) return null + + return when (status) { + PasswordHealthStatus.NO_DATA -> null + PasswordHealthStatus.FAILED -> stringResource(R.string.password_health_retry_hint) + PasswordHealthStatus.ALL_GOOD -> pluralStringResource( + R.plurals.detail_line_checked, + totalPasswordCount, + totalPasswordCount + ) + + PasswordHealthStatus.NEEDS_ATTENTION -> { + listOfNotNull( + countLabel(R.string.detail_line_checked_short, totalPasswordCount), + countLabel(R.string.detail_line_breached, summary.breached), + countLabel(R.string.detail_line_weak, summary.weak), + countLabel(R.string.detail_line_reused, summary.reused), + countLabel(R.string.detail_line_similar, summary.similar), + ).joinToString(" \u2022 ") + } + } +} + +@Composable +@ReadOnlyComposable +internal fun PasswordHealthUiState.coverageNote(): String? { + if (isFirstLoad || unreadable.isEmpty()) return null + + return pluralStringResource( + R.plurals.password_health_unreadable_count, + unreadable.size, + unreadable.size, + ) +} + +@Composable +@ReadOnlyComposable +private fun PasswordHealthReportError?.failureMessage(): String = when (this) { + PasswordHealthReportError.Unreadable -> stringResource(R.string.password_health_unreadable) + PasswordHealthReportError.NoPasswords, null -> + stringResource(R.string.password_health_check_failed) +} + +@Composable +@ReadOnlyComposable +private fun countLabel(@StringRes id: Int, count: Int): String? = + if (count > 0) stringResource(id, count, count) else null diff --git a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/worker/HealthCheckNotifier.kt b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/worker/HealthCheckNotifier.kt new file mode 100644 index 000000000..1ec040acd --- /dev/null +++ b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/worker/HealthCheckNotifier.kt @@ -0,0 +1,24 @@ +package de.davis.keygo.feature.password_health.worker + +import android.content.Context +import androidx.work.CoroutineWorker +import androidx.work.WorkerParameters +import de.davis.keygo.feature.password_health.domain.usecase.SendHealthReminderUseCase +import org.koin.android.annotation.KoinWorker + +@KoinWorker +internal class HealthCheckNotifier( + appContext: Context, + params: WorkerParameters, + private val sendHealthReminder: SendHealthReminderUseCase, +) : CoroutineWorker(appContext, params) { + + override suspend fun doWork(): Result { + sendHealthReminder() + return Result.success() + } + + companion object { + const val UNIQUE_WORK_NAME = "health-check-notifier" + } +} diff --git a/feature/password-health/src/main/proto/health_notification_state.proto b/feature/password-health/src/main/proto/health_notification_state.proto new file mode 100644 index 000000000..b6152e239 --- /dev/null +++ b/feature/password-health/src/main/proto/health_notification_state.proto @@ -0,0 +1,24 @@ +syntax = "proto3"; + +package de.davis.keygo.feature.password_health.data.local.model; +option java_multiple_files = true; + +// Deliberately unencrypted: the reminder worker runs without a session. Only counts and a digest +// over password ciphertexts, never which items are affected. +message ProtoHealthNotificationState { + ProtoHealthSnapshot snapshot = 1; + ProtoHealthReminder reminder = 2; +} + +message ProtoHealthSnapshot { + int32 attention_count = 1; + int32 breached_count = 2; + bytes vault_fingerprint = 3; +} + +message ProtoHealthReminder { + int32 attention_count = 1; + int32 breached_count = 2; + int64 sent_at_epoch_millis = 3; + int32 repeats = 4; +} diff --git a/feature/password-health/src/main/proto/health_settings.proto b/feature/password-health/src/main/proto/health_settings.proto new file mode 100644 index 000000000..f32d084b7 --- /dev/null +++ b/feature/password-health/src/main/proto/health_settings.proto @@ -0,0 +1,9 @@ +syntax = "proto3"; + +package de.davis.keygo.feature.password_health.data.local.model; +option java_multiple_files = true; + +message ProtoHealthSettings { + bool breachesEnabled = 1; + bool notificationsEnabled = 2; +} diff --git a/feature/password-health/src/main/proto/password_health_report.proto b/feature/password-health/src/main/proto/password_health_report.proto new file mode 100644 index 000000000..b46b9471e --- /dev/null +++ b/feature/password-health/src/main/proto/password_health_report.proto @@ -0,0 +1,66 @@ +syntax = "proto3"; + +package de.davis.keygo.feature.password_health.data.local.model; +option java_multiple_files = true; + +message ProtoHealthReportStore { + bytes wrapped_key = 1; + bytes wrapped_key_nonce = 2; + bytes ciphertext = 3; + bytes iv = 4; +} + +message ProtoStoredHealthReport { + repeated ProtoStoredItem items = 1; + repeated ProtoRelationFinding relation_finding = 2; + repeated ProtoCheckGap gaps = 3; + int32 algorithm_version = 4; + bool breach_check_enabled = 5; +} + +message ProtoStoredItem { + bytes id = 1; // 16 raw bytes of the UUID + bytes fingerprint = 2; // raw digest, not hex/base64 + optional int32 breach_occurrences = 3; + ProtoPasswordScore score = 4; + bool unreadable = 5; + optional int64 breach_checked_at_epoch_millis = 6; // unset = never looked up +} + +message ProtoRelationFinding { + repeated uint32 item_refs = 1; + ProtoRelationType type = 2; +} + +message ProtoCheckGap { + ProtoCheckKind kind = 1; + ProtoGapReason reason = 2; + repeated uint32 unchecked_item_refs = 3; +} + +enum ProtoPasswordScore { + PASSWORD_SCORE_NONE = 0; + PASSWORD_SCORE_RIDICULOUS = 1; + PASSWORD_SCORE_WEAK = 2; + PASSWORD_SCORE_MODERATE = 3; + PASSWORD_SCORE_STRONG = 4; + PASSWORD_SCORE_EXCELLENT = 5; +} + +enum ProtoRelationType { + RELATION_TYPE_REUSED = 0; + RELATION_TYPE_SIMILAR = 1; +} + +enum ProtoCheckKind { + CHECK_KIND_STRENGTH = 0; + CHECK_KIND_REUSE = 1; + CHECK_KIND_BREACH = 2; + CHECK_KIND_SIMILARITY = 3; +} + +enum ProtoGapReason { + GAP_REASON_DISABLED = 0; + GAP_REASON_UNREACHABLE = 1; + GAP_REASON_FAILED = 2; +} diff --git a/feature/password-health/src/main/res/values/strings.xml b/feature/password-health/src/main/res/values/strings.xml new file mode 100644 index 000000000..0e94fb5a5 --- /dev/null +++ b/feature/password-health/src/main/res/values/strings.xml @@ -0,0 +1,101 @@ + + + Password Health + + Overview + Add some passwords to see how they\'re doing + All your passwords look great + Every password that could be checked looks great + + %1$d password needs attention + %1$d passwords need attention + + Checking your passwords… + Couldn\'t check your passwords + None of your passwords could be opened for checking + Pull down to try again + + %1$d password couldn\'t be opened and went unchecked + %1$d passwords couldn\'t be opened and went unchecked + + + + %1$d password checked + %1$d passwords checked + + %1$d checked + %1$d breached + %1$d weak + %1$d reused + %1$d similar + + %1$d critical + %1$d high + %1$d to review + %1$d fine + + Needs attention + Weak + Reused + Breached + + Critical + High + Needs review + + + Reused across %1$d login + Reused across %1$d logins + + + Similar password across %1$d login + Similar passwords across %1$d logins + + + + Breached password on %1$d login + Breached passwords on %1$d logins + + + Weak password on %1$d login + Weak passwords on %1$d logins + + + + Found in %1$d breach + Found in %1$d breaches + + + Check for breaches + Only the first 5 characters of each password\'s hash ever leave your device + + Breach check failed for %1$d password: no connection to the breach service + Breach check failed for %1$d passwords: no connection to the breach service + + + Breach check failed for %1$d password: the breach service didn\'t respond + Breach check failed for %1$d passwords: the breach service didn\'t respond + + + Generated password + New password copied. Change it where this login is used, then confirm here. + Open site + I changed it + Cancel + Your session ended before it could be saved. Try again. + Couldn\'t save the new password. Try again. + + Pick an item to see it here + + Enable notifications + KeyGo will remind you of any security issues with your passwords. + Notification permission + KeyGo needs permission to send notifications to remind you about passwords that need attention. + Notifications are turned off for KeyGo. To get password reminders, allow them in your app settings. + Grant + Open settings + Cancel + + Password health + Reminders when some of your passwords need attention + diff --git a/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/FakeBreachedRepository.kt b/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/FakeBreachedRepository.kt new file mode 100644 index 000000000..c6dca8b9e --- /dev/null +++ b/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/FakeBreachedRepository.kt @@ -0,0 +1,35 @@ +package de.davis.keygo.feature.password_health + +import de.davis.keygo.core.util.Result +import de.davis.keygo.feature.password_health.domain.model.BreachedError +import de.davis.keygo.feature.password_health.domain.repository.BreachedRepository +import de.davis.keygo.feature.password_health.domain.repository.BreachedRepository.Companion.PREFIX_LENGTH +import java.util.Collections + +/** + * Answers range lookups from [breaches], keyed by the full upper-case SHA-1 hex of a password. + * A prefix in [failures] fails with its error instead. + */ +internal class FakeBreachedRepository( + var breaches: Map = emptyMap(), + val failures: MutableMap = mutableMapOf(), +) : BreachedRepository { + + data class Call(val prefix: String, val suffixes: Set) + + val calls: MutableList = Collections.synchronizedList(mutableListOf()) + + override suspend fun occurrences( + prefix: String, + suffixes: Set, + ): Result, BreachedError> { + calls += Call(prefix, suffixes) + failures[prefix]?.let { return Result.Failure(it) } + + return Result.Success( + breaches + .filterKeys { it.startsWith(prefix) && it.substring(PREFIX_LENGTH) in suffixes } + .mapKeys { (hash, _) -> hash.substring(PREFIX_LENGTH) }, + ) + } +} diff --git a/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/FakeConnectivityRepository.kt b/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/FakeConnectivityRepository.kt new file mode 100644 index 000000000..150810f6e --- /dev/null +++ b/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/FakeConnectivityRepository.kt @@ -0,0 +1,18 @@ +package de.davis.keygo.feature.password_health + +import de.davis.keygo.feature.password_health.domain.repository.ConnectivityRepository +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow + +internal class FakeConnectivityRepository(online: Boolean = true) : ConnectivityRepository { + + private val internet = MutableStateFlow(online) + + var online: Boolean + get() = internet.value + set(value) { + internet.value = value + } + + override fun observeInternet(): Flow = internet +} diff --git a/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/FakeHealthCheckNotifierScheduler.kt b/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/FakeHealthCheckNotifierScheduler.kt new file mode 100644 index 000000000..cbb1b1e62 --- /dev/null +++ b/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/FakeHealthCheckNotifierScheduler.kt @@ -0,0 +1,17 @@ +package de.davis.keygo.feature.password_health + +import de.davis.keygo.feature.password_health.domain.HealthCheckNotifierScheduler + +internal class FakeHealthCheckNotifierScheduler : HealthCheckNotifierScheduler { + + var scheduled = false + private set + + override fun scheduleHealthReminder() { + scheduled = true + } + + override fun cancel() { + scheduled = false + } +} diff --git a/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/FakeHealthNotificationStateRepository.kt b/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/FakeHealthNotificationStateRepository.kt new file mode 100644 index 000000000..ab5966325 --- /dev/null +++ b/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/FakeHealthNotificationStateRepository.kt @@ -0,0 +1,25 @@ +package de.davis.keygo.feature.password_health + +import de.davis.keygo.feature.password_health.domain.model.HealthNotificationState +import de.davis.keygo.feature.password_health.domain.model.HealthReminder +import de.davis.keygo.feature.password_health.domain.model.HealthSnapshot +import de.davis.keygo.feature.password_health.domain.repository.HealthNotificationStateRepository + +internal class FakeHealthNotificationStateRepository( + var state: HealthNotificationState = HealthNotificationState(), +) : HealthNotificationStateRepository { + + override suspend fun get(): HealthNotificationState = state + + override suspend fun setSnapshot(snapshot: HealthSnapshot?) { + state = state.copy(snapshot = snapshot) + } + + override suspend fun setReminder(reminder: HealthReminder?) { + state = state.copy(reminder = reminder) + } + + override suspend fun clear() { + state = HealthNotificationState() + } +} diff --git a/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/FakeHealthSettingsRepository.kt b/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/FakeHealthSettingsRepository.kt new file mode 100644 index 000000000..e89d143fc --- /dev/null +++ b/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/FakeHealthSettingsRepository.kt @@ -0,0 +1,32 @@ +package de.davis.keygo.feature.password_health + +import de.davis.keygo.feature.password_health.domain.model.HealthSettings +import de.davis.keygo.feature.password_health.domain.repository.HealthSettingsRepository +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.update + +internal class FakeHealthSettingsRepository( + breachesEnabled: Boolean = true, + notificationsEnabled: Boolean = false, +) : HealthSettingsRepository { + + val state = MutableStateFlow( + HealthSettings( + breachesEnabled = breachesEnabled, + notificationsEnabled = notificationsEnabled, + ) + ) + + override fun observeBreachCheckState(): Flow = state + + override suspend fun getBreachCheckState(): HealthSettings = state.value + + override suspend fun setBreachEnabled(enabled: Boolean) { + state.update { it.copy(breachesEnabled = enabled) } + } + + override suspend fun setNotificationEnabled(enabled: Boolean) { + state.update { it.copy(notificationsEnabled = enabled) } + } +} diff --git a/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/FakeNotifier.kt b/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/FakeNotifier.kt new file mode 100644 index 000000000..b21919bcb --- /dev/null +++ b/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/FakeNotifier.kt @@ -0,0 +1,20 @@ +package de.davis.keygo.feature.password_health + +import de.davis.keygo.feature.password_health.domain.Notifier +import de.davis.keygo.feature.password_health.domain.model.KeyGoNotification + +internal class FakeNotifier(var canNotify: Boolean = true) : Notifier { + + val sent = mutableListOf() + val cancelled = mutableListOf() + + override fun canNotify(): Boolean = canNotify + + override fun sendNotification(notification: KeyGoNotification) { + sent += notification + } + + override fun cancel(kind: KeyGoNotification.Kind) { + cancelled += kind + } +} diff --git a/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/data/LoginFingerprinterImplTest.kt b/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/data/LoginFingerprinterImplTest.kt new file mode 100644 index 000000000..8d4b682d7 --- /dev/null +++ b/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/data/LoginFingerprinterImplTest.kt @@ -0,0 +1,118 @@ +package de.davis.keygo.feature.password_health.data + +import de.davis.keygo.core.item.domain.model.EncryptedPayload +import de.davis.keygo.core.item.domain.model.Login +import de.davis.keygo.core.item.domain.model.PasswordCredential +import de.davis.keygo.core.item.domain.model.PasswordScore +import de.davis.keygo.core.item.domain.model.PasswordSecret +import de.davis.keygo.feature.password_health.domain.model.HealthFingerprint +import de.davis.keygo.feature.password_health.domain.report.id +import de.davis.keygo.feature.password_health.domain.report.login +import kotlinx.coroutines.test.runTest +import java.nio.ByteBuffer +import java.security.MessageDigest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull + +class LoginFingerprinterImplTest { + + private val fingerprinter = LoginFingerprinterImpl() + + @Test + fun aLoginWithoutAPasswordHasNoFingerprint() = runTest { + val login = login(id(0)).copy(passwordCredential = null) + + assertNull(fingerprinter.fingerprint(login)) + } + + @Test + fun isTheSha256OfTheIdAndTheLengthPrefixedPayload() = runTest { + val login = login(id(1)).withPayload(byteArrayOf(1, 2, 3), byteArrayOf(9, 8)) + + val expected = MessageDigest.getInstance("SHA-256").run { + update(ByteArray(15) + byteArrayOf(1)) + update(ByteBuffer.allocate(4).putInt(3).array()) + update(byteArrayOf(1, 2, 3)) + update(ByteBuffer.allocate(4).putInt(2).array()) + update(byteArrayOf(9, 8)) + digest() + } + + assertEquals(HealthFingerprint(expected), fingerprinter.fingerprint(login)) + } + + @Test + fun isStableForTheSameLogin() = runTest { + val login = login(id(0)) + + assertEquals(fingerprinter.fingerprint(login), fingerprinter.fingerprint(login)) + } + + @Test + fun isA32ByteDigest() = runTest { + assertEquals(32, assertNotNull(fingerprinter.fingerprint(login(id(0)))).value.size) + } + + @Test + fun ignoresEverythingButTheIdAndThePasswordPayload() = runTest { + val login = login(id(0)) + val edited = login.copy( + name = "renamed", + username = "someone-else", + note = "a note", + pinned = true, + passwordCredential = login.passwordCredential!!.copy(score = PasswordScore.Weak), + ) + + assertEquals(fingerprinter.fingerprint(login), fingerprinter.fingerprint(edited)) + } + + @Test + fun changesWithTheCiphertext() = runTest { + val a = login(id(0)).withPayload(byteArrayOf(1), byteArrayOf(0)) + val b = login(id(0)).withPayload(byteArrayOf(2), byteArrayOf(0)) + + assertNotEquals(fingerprinter.fingerprint(a), fingerprinter.fingerprint(b)) + } + + @Test + fun changesWithTheIv() = runTest { + val a = login(id(0)).withPayload(byteArrayOf(1), byteArrayOf(0)) + val b = login(id(0)).withPayload(byteArrayOf(1), byteArrayOf(1)) + + assertNotEquals(fingerprinter.fingerprint(a), fingerprinter.fingerprint(b)) + } + + @Test + fun changesWithTheId() = runTest { + val a = login(id(0)).withPayload(byteArrayOf(1), byteArrayOf(0)) + val b = login(id(1)).withPayload(byteArrayOf(1), byteArrayOf(0)) + + assertNotEquals(fingerprinter.fingerprint(a), fingerprinter.fingerprint(b)) + } + + @Test + fun movingABytePastTheCiphertextBoundaryChangesIt() = runTest { + val a = login(id(0)).withPayload(byteArrayOf(1, 2), byteArrayOf(3)) + val b = login(id(0)).withPayload(byteArrayOf(1), byteArrayOf(2, 3)) + + assertNotEquals(fingerprinter.fingerprint(a), fingerprinter.fingerprint(b)) + } + + @Test + fun anEmptyPayloadStillHasAFingerprint() = runTest { + val login = login(id(0)).withPayload(byteArrayOf(), byteArrayOf()) + + assertNotNull(fingerprinter.fingerprint(login)) + } + + private fun Login.withPayload(ciphertext: ByteArray, iv: ByteArray) = copy( + passwordCredential = PasswordCredential( + secret = PasswordSecret(EncryptedPayload(ciphertext = ciphertext, iv = iv)), + score = PasswordScore.Strong, + ), + ) +} diff --git a/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/data/mapper/StoredHealthReportMapperTest.kt b/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/data/mapper/StoredHealthReportMapperTest.kt new file mode 100644 index 000000000..f99f7b6d6 --- /dev/null +++ b/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/data/mapper/StoredHealthReportMapperTest.kt @@ -0,0 +1,338 @@ +package de.davis.keygo.feature.password_health.data.mapper + +import com.google.protobuf.kotlin.toByteString +import de.davis.keygo.core.item.domain.model.PasswordScore +import de.davis.keygo.feature.password_health.data.local.model.ProtoStoredHealthReport +import de.davis.keygo.feature.password_health.data.local.model.ProtoStoredHealthReportKt +import de.davis.keygo.feature.password_health.data.local.model.protoCheckGap +import de.davis.keygo.feature.password_health.data.local.model.protoRelationFinding +import de.davis.keygo.feature.password_health.data.local.model.protoStoredHealthReport +import de.davis.keygo.feature.password_health.data.local.model.protoStoredItem +import de.davis.keygo.feature.password_health.domain.model.CheckGap +import de.davis.keygo.feature.password_health.domain.model.CheckKind +import de.davis.keygo.feature.password_health.domain.model.GapReason +import de.davis.keygo.feature.password_health.domain.model.HealthFinding +import de.davis.keygo.feature.password_health.domain.model.RelationType +import de.davis.keygo.feature.password_health.domain.model.StoredHealthItem +import de.davis.keygo.feature.password_health.domain.model.StoredHealthReport +import de.davis.keygo.feature.password_health.domain.report.breach +import de.davis.keygo.feature.password_health.domain.report.id +import de.davis.keygo.feature.password_health.domain.report.storedItem +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import java.io.ByteArrayInputStream +import java.io.ByteArrayOutputStream +import java.util.UUID +import java.util.zip.GZIPInputStream +import java.util.zip.GZIPOutputStream +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.time.Instant + +// Robolectric only so a rejected report can reach android.util.Log. +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34]) +class StoredHealthReportMapperTest { + + private val checkedAt = Instant.fromEpochMilliseconds(1_700_000_000_123) + + @Test + fun aFullReportSurvivesTheRoundTrip() { + val report = StoredHealthReport( + items = listOf( + storedItem(id(0), score = PasswordScore.Weak, breach = breach(12, checkedAt)), + storedItem(id(1), score = PasswordScore.Ridiculous), + storedItem(id(2), breach = breach(0, checkedAt)), + storedItem(id(3), unreadable = true), + ), + relationalFindings = listOf( + HealthFinding.Relation(setOf(id(0), id(1)), RelationType.Reused), + HealthFinding.Relation(setOf(id(1), id(2), id(3)), RelationType.Similar), + ), + gaps = mapOf( + CheckKind.Breach to CheckGap(GapReason.Unreachable, setOf(id(3))), + CheckKind.Similarity to CheckGap(GapReason.Failed, setOf(id(0), id(2))), + ), + breachCheckEnabled = true, + ) + + assertEquals(report, report.roundTrip()) + } + + @Test + fun anEmptyReportSurvivesTheRoundTrip() { + val report = StoredHealthReport( + items = emptyList(), + relationalFindings = emptyList(), + breachCheckEnabled = false, + ) + + assertEquals(report, report.roundTrip()) + } + + @Test + fun everyScoreSurvivesTheRoundTrip() { + val scores = listOf( + PasswordScore.Ridiculous, + PasswordScore.Weak, + PasswordScore.Moderate, + PasswordScore.Strong, + PasswordScore.Excellent, + ) + val report = reportOf(scores.mapIndexed { i, score -> storedItem(id(i), score = score) }) + + assertEquals(scores, report.roundTrip().items.map { it.score }) + } + + @Test + fun aNoneScoreIsReadBackAsNoScore() { + val report = reportOf(listOf(storedItem(id(0), score = PasswordScore.None))) + + assertNull(report.roundTrip().items.single().score) + } + + @Test + fun everyGapKindAndReasonSurvivesTheRoundTrip() { + val kinds = listOf(CheckKind.Strength, CheckKind.Reuse, CheckKind.Breach, CheckKind.Similarity) + val reasons = listOf(GapReason.Disabled, GapReason.Unreachable, GapReason.Failed) + + reasons.forEach { reason -> + val report = reportOf( + items = listOf(storedItem(id(0))), + gaps = kinds.associateWith { CheckGap(reason, setOf(id(0))) }, + ) + + assertEquals(report.gaps, report.roundTrip().gaps) + } + } + + @Test + fun aCleanBreachLookupIsToldApartFromNoLookup() { + val report = reportOf( + listOf( + storedItem(id(0), breach = breach(0, checkedAt)), + storedItem(id(1), breach = null), + ), + ) + + assertEquals(listOf(breach(0, checkedAt), null), report.roundTrip().items.map { it.breach }) + } + + @Test + fun theLookupTimeKeepsMillisecondPrecision() { + val precise = Instant.fromEpochSeconds(1_700_000_000, 123_456_789) + val report = reportOf(listOf(storedItem(id(0), breach = breach(1, precise)))) + + assertEquals( + Instant.fromEpochMilliseconds(precise.toEpochMilliseconds()), + report.roundTrip().items.single().breach?.checkedAt, + ) + } + + @Test + fun aRandomIdSurvivesTheRoundTrip() { + val random = UUID.randomUUID() + val report = reportOf(listOf(storedItem(random))) + + assertEquals(random, report.roundTrip().items.single().id) + } + + @Test + fun theStoredFormIsGzipped() { + val bytes = reportOf(listOf(storedItem(id(0)))).toCompressedByteArray() + + assertEquals(listOf(0x1f.toByte(), 0x8b.toByte()), bytes.take(2)) + } + + @Test + fun theStoredFormCarriesTheAlgorithmVersion() { + val bytes = reportOf(listOf(storedItem(id(0)))).toCompressedByteArray() + + assertEquals(StoredHealthReport.ALGORITHM_VERSION, bytes.unzipProto().algorithmVersion) + } + + @Test + fun aReportFromAnotherAlgorithmVersionIsDiscarded() { + val bytes = protoStoredHealthReport { + algorithmVersion = StoredHealthReport.ALGORITHM_VERSION + 1 + }.gzip() + + assertNull(bytes.toStoredHealthReport()) + } + + @Test + fun aReportWithoutAnyVersionIsDiscarded() { + assertNull(protoStoredHealthReport { }.gzip().toStoredHealthReport()) + } + + @Test + fun aGapOfAnUnknownKindIsSkipped() { + val bytes = versioned { + items += item(0) + gaps += protoCheckGap { + kindValue = 99 + uncheckedItemRefs += 0 + } + } + + assertEquals(emptyMap(), assertNotNullReport(bytes).gaps) + } + + @Test + fun aGapForAnUnknownReasonIsSkipped() { + val bytes = versioned { + items += item(0) + gaps += protoCheckGap { + reasonValue = 99 + uncheckedItemRefs += 0 + } + } + + assertEquals(emptyMap(), assertNotNullReport(bytes).gaps) + } + + @Test + fun aRelationOfAnUnknownTypeIsSkipped() { + val bytes = versioned { + items += item(0) + items += item(1) + relationFinding += protoRelationFinding { + itemRefs += listOf(0, 1) + typeValue = 99 + } + } + + assertEquals(emptyList(), assertNotNullReport(bytes).relationalFindings) + } + + @Test + fun anUnknownScoreIsReadAsNoScore() { + val bytes = versioned { + items += protoStoredItem { + id = ByteArray(16).toByteString() + fingerprint = byteArrayOf(1).toByteString() + scoreValue = 99 + } + } + + assertNull(assertNotNullReport(bytes).items.single().score) + } + + @Test + fun aRelationPointingPastTheItemsIsRejected() { + val bytes = versioned { + items += item(0) + relationFinding += protoRelationFinding { itemRefs += listOf(0, 5) } + } + + assertNull(bytes.toStoredHealthReport()) + } + + @Test + fun aGapPointingPastTheItemsIsRejected() { + val bytes = versioned { + items += item(0) + gaps += protoCheckGap { uncheckedItemRefs += 1 } + } + + assertNull(bytes.toStoredHealthReport()) + } + + @Test + fun twoGapsForTheSameKindAreRejected() { + val bytes = versioned { + items += item(0) + gaps += protoCheckGap { uncheckedItemRefs += 0 } + gaps += protoCheckGap { uncheckedItemRefs += 0 } + } + + assertNull(bytes.toStoredHealthReport()) + } + + @Test + fun anItemListedTwiceCannotBeStored() { + val report = reportOf(listOf(storedItem(id(0)), storedItem(id(0)))) + + assertFailsWith { report.toCompressedByteArray() } + } + + @Test + fun aRelationAboutAnItemNotInTheReportCannotBeStored() { + val report = reportOf( + items = listOf(storedItem(id(0))), + relations = listOf(HealthFinding.Relation(setOf(id(0), id(1)), RelationType.Reused)), + ) + + assertFailsWith { report.toCompressedByteArray() } + } + + @Test + fun aGapAboutAnItemNotInTheReportCannotBeStored() { + val report = reportOf( + items = listOf(storedItem(id(0))), + gaps = mapOf(CheckKind.Breach to CheckGap(GapReason.Failed, setOf(id(1)))), + ) + + assertFailsWith { report.toCompressedByteArray() } + } + + @Test + fun bytesThatAreNotGzipAreRejected() { + assertNull(byteArrayOf(1, 2, 3).toStoredHealthReport()) + } + + @Test + fun aTruncatedProtoIsRejected() { + val bytes = ByteArrayOutputStream().also { bos -> + GZIPOutputStream(bos).use { it.write(byteArrayOf(0x0a, 0x7f, 0x01)) } + }.toByteArray() + + assertNull(bytes.toStoredHealthReport()) + } + + @Test + fun emptyBytesAreRejected() { + assertNull(byteArrayOf().toStoredHealthReport()) + } + + private fun StoredHealthReport.roundTrip() = + assertNotNullReport(toCompressedByteArray()) + + private fun assertNotNullReport(bytes: ByteArray) = + assertNotNull(bytes.toStoredHealthReport()) + + private fun reportOf( + items: List, + relations: List = emptyList(), + gaps: Map = emptyMap(), + ) = StoredHealthReport( + items = items, + relationalFindings = relations, + gaps = gaps, + breachCheckEnabled = true, + ) + + private fun item(n: Int) = protoStoredItem { + id = ByteArray(15).plus(n.toByte()).toByteString() + fingerprint = byteArrayOf(n.toByte()).toByteString() + } + + private fun versioned( + block: ProtoStoredHealthReportKt.Dsl.() -> Unit, + ) = protoStoredHealthReport { + algorithmVersion = StoredHealthReport.ALGORITHM_VERSION + block() + }.gzip() + + private fun ProtoStoredHealthReport.gzip(): ByteArray = ByteArrayOutputStream().also { bos -> + GZIPOutputStream(bos).use { it.write(toByteArray()) } + }.toByteArray() + + private fun ByteArray.unzipProto() = ProtoStoredHealthReport.parseFrom( + GZIPInputStream(ByteArrayInputStream(this)).use { it.readBytes() }, + ) +} diff --git a/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/data/repository/BreachedRepositoryImplTest.kt b/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/data/repository/BreachedRepositoryImplTest.kt new file mode 100644 index 000000000..1fa8c56a3 --- /dev/null +++ b/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/data/repository/BreachedRepositoryImplTest.kt @@ -0,0 +1,360 @@ +package de.davis.keygo.feature.password_health.data.repository + +import de.davis.keygo.core.util.assertFailure +import de.davis.keygo.core.util.assertSuccess +import de.davis.keygo.feature.password_health.domain.model.BreachedError +import kotlinx.coroutines.test.runTest +import okhttp3.Interceptor +import okhttp3.OkHttpClient +import okhttp3.Protocol +import okhttp3.Request +import okhttp3.Response +import okhttp3.ResponseBody.Companion.toResponseBody +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import java.io.IOException +import java.util.Collections +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +// Robolectric only so the warnings can reach android.util.Log. +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34]) +class BreachedRepositoryImplTest { + + private val api = FakeRangeApi() + private val repository = BreachedRepositoryImpl( + OkHttpClient.Builder().addInterceptor(api).build(), + ) + + @Test + fun asksThePwnedPasswordsRangeEndpointForThePrefix() = runTest { + api.answer(body = "") + + repository.occurrences(PREFIX, setOf(SUFFIX)) + + assertEquals( + "https://api.pwnedpasswords.com/range/5BAA6", + api.requests.single().url.toString(), + ) + } + + @Test + fun asksForAPaddedUncachedAnswer() = runTest { + api.answer(body = "") + + repository.occurrences(PREFIX, setOf(SUFFIX)) + + val request = api.requests.single() + assertEquals("true", request.header("Add-Padding")) + assertEquals("no-store", request.header("Cache-Control")) + assertEquals("GET", request.method) + } + + @Test + fun neverSendsTheSuffixes() = runTest { + api.answer(body = "") + + repository.occurrences(PREFIX, setOf(SUFFIX)) + + assertTrue(SUFFIX !in api.requests.single().url.toString()) + } + + @Test + fun readsTheCountOfAnAskedSuffix() = runTest { + api.answer(body = "0018A45C4D1DEF81644B54AB7F969B88D65:1\n$SUFFIX:9545824\n") + + val counts = repository.occurrences(PREFIX, setOf(SUFFIX)).assertSuccess() + + assertEquals(mapOf(SUFFIX to 9_545_824), counts) + } + + @Test + fun readsSeveralAskedSuffixesFromOneRange() = runTest { + api.answer(body = "AAA:1\nBBB:2\nCCC:3\n") + + val counts = repository.occurrences(PREFIX, setOf("AAA", "CCC")).assertSuccess() + + assertEquals(mapOf("AAA" to 1, "CCC" to 3), counts) + } + + @Test + fun aSuffixMissingFromTheRangeIsClean() = runTest { + api.answer(body = "AAA:1\nBBB:2\n") + + val counts = repository.occurrences(PREFIX, setOf(SUFFIX)).assertSuccess() + + assertEquals(emptyMap(), counts) + } + + @Test + fun matchesTheSuffixWhateverItsCaseAndKeysItAsAsked() = runTest { + api.answer(body = "${SUFFIX.lowercase()}:4\n") + + val counts = repository.occurrences(PREFIX, setOf(SUFFIX)).assertSuccess() + + assertEquals(mapOf(SUFFIX to 4), counts) + } + + @Test + fun aPaddingLineWithAZeroCountIsClean() = runTest { + api.answer(body = "$SUFFIX:0\n") + + val counts = repository.occurrences(PREFIX, setOf(SUFFIX)).assertSuccess() + + assertEquals(emptyMap(), counts) + } + + @Test + fun aLongerSuffixSharingTheStartIsNotAMatch() = runTest { + api.answer(body = "AAAB:5\n") + + val counts = repository.occurrences(PREFIX, setOf("AAA")).assertSuccess() + + assertEquals(emptyMap(), counts) + } + + @Test + fun malformedLinesAreSkipped() = runTest { + api.answer(body = "garbage\n:12\nAAA:many\nAAA:\n\nBBB:2\n") + + val counts = repository.occurrences(PREFIX, setOf("AAA", "BBB")).assertSuccess() + + assertEquals(mapOf("BBB" to 2), counts) + } + + @Test + fun windowsLineEndingsAndPaddedCountsAreRead() = runTest { + api.answer(body = "AAA: 7 \r\nBBB:2\r\n") + + val counts = repository.occurrences(PREFIX, setOf("AAA", "BBB")).assertSuccess() + + assertEquals(mapOf("AAA" to 7, "BBB" to 2), counts) + } + + @Test + fun aRangeWithoutATrailingNewlineIsReadToTheEnd() = runTest { + api.answer(body = "AAA:1\nBBB:2") + + val counts = repository.occurrences(PREFIX, setOf("BBB")).assertSuccess() + + assertEquals(mapOf("BBB" to 2), counts) + } + + @Test + fun anEmptyRangeIsClean() = runTest { + api.answer(body = "") + + assertEquals(emptyMap(), repository.occurrences(PREFIX, setOf(SUFFIX)).assertSuccess()) + } + + @Test + fun aRangeAsLongAsARealOneIsRead() = runTest { + api.answer(body = paddedRange(lines = 2_000, hit = "$SUFFIX:3")) + + assertEquals( + mapOf(SUFFIX to 3), + repository.occurrences(PREFIX, setOf(SUFFIX)).assertSuccess(), + ) + } + + @Test + fun aRangeThatNeverEndsIsNotTakenAsClean() = runTest { + api.answer(body = paddedRange(lines = 20_000, hit = null)) + + assertEquals( + BreachedError.ApiFailed, + repository.occurrences(PREFIX, setOf(SUFFIX)).assertFailure(), + ) + } + + @Test + fun aRangeThatNeverEndsFailsEvenWhenTheHitCameEarly() = runTest { + api.answer(body = "$SUFFIX:3\n" + paddedRange(lines = 20_000, hit = null)) + + assertEquals( + BreachedError.ApiFailed, + repository.occurrences(PREFIX, setOf(SUFFIX)).assertFailure(), + ) + } + + @Test + fun aServerErrorIsAnApiFailure() = runTest { + api.answer(code = 500) + + assertEquals( + BreachedError.ApiFailed, + repository.occurrences(PREFIX, setOf(SUFFIX)).assertFailure(), + ) + assertEquals(1, api.requests.size) + } + + @Test + fun aClientErrorIsAnApiFailure() = runTest { + api.answer(code = 400) + + assertEquals( + BreachedError.ApiFailed, + repository.occurrences(PREFIX, setOf(SUFFIX)).assertFailure(), + ) + } + + @Test + fun aDroppedConnectionIsUnreachable() = runTest { + api.fail() + + assertEquals( + BreachedError.Unreachable, + repository.occurrences(PREFIX, setOf(SUFFIX)).assertFailure(), + ) + assertEquals(1, api.requests.size) + } + + @Test + fun aThrottledLookupIsRetried() = runTest { + api.answer(code = 429) + api.answer(body = "$SUFFIX:2\n") + + val counts = repository.occurrences(PREFIX, setOf(SUFFIX)).assertSuccess() + + assertEquals(mapOf(SUFFIX to 2), counts) + assertEquals(2, api.requests.size) + } + + @Test + fun aRetryThatFailsDifferentlyReportsThatFailure() = runTest { + api.answer(code = 429) + api.fail() + + assertEquals( + BreachedError.Unreachable, + repository.occurrences(PREFIX, setOf(SUFFIX)).assertFailure(), + ) + } + + @Test + fun givesUpAfterThreeThrottledAttempts() = runTest { + repeat(5) { api.answer(code = 429) } + + assertEquals( + BreachedError.ApiFailed, + repository.occurrences(PREFIX, setOf(SUFFIX)).assertFailure(), + ) + assertEquals(3, api.requests.size) + } + + @Test + fun waitsAsLongAsRetryAfterAsks() = runTest { + api.answer(code = 429, retryAfter = "3") + api.answer(body = "") + + repository.occurrences(PREFIX, setOf(SUFFIX)) + + assertEquals(3_000, testScheduler.currentTime) + } + + @Test + fun waitsAtLeastASecond() = runTest { + api.answer(code = 429, retryAfter = "0") + api.answer(body = "") + + repository.occurrences(PREFIX, setOf(SUFFIX)) + + assertEquals(1_000, testScheduler.currentTime) + } + + @Test + fun waitsAtMostTenSeconds() = runTest { + api.answer(code = 429, retryAfter = "3600") + api.answer(body = "") + + repository.occurrences(PREFIX, setOf(SUFFIX)) + + assertEquals(10_000, testScheduler.currentTime) + } + + @Test + fun waitsASecondWithoutRetryAfter() = runTest { + api.answer(code = 429) + api.answer(body = "") + + repository.occurrences(PREFIX, setOf(SUFFIX)) + + assertEquals(1_000, testScheduler.currentTime) + } + + @Test + fun waitsASecondWhenRetryAfterIsADate() = runTest { + api.answer(code = 429, retryAfter = "Wed, 21 Oct 2015 07:28:00 GMT") + api.answer(body = "") + + repository.occurrences(PREFIX, setOf(SUFFIX)) + + assertEquals(1_000, testScheduler.currentTime) + } + + @Test + fun noSuffixesAsksNobody() = runTest { + assertEquals(emptyMap(), repository.occurrences(PREFIX, emptySet()).assertSuccess()) + assertTrue(api.requests.isEmpty()) + } + + @Test + fun aLowerCasePrefixIsAccepted() = runTest { + api.answer(body = "") + + repository.occurrences("5baa6", setOf(SUFFIX)).assertSuccess() + } + + @Test + fun aPrefixThatIsNotFiveHexCharactersIsRefusedWithoutAsking() = runTest { + listOf("", "5BAA", "5BAA61", "5BAG6", "5BA 6", "../x", "5BAA6/").forEach { prefix -> + assertEquals( + BreachedError.InvalidPrefix, + repository.occurrences(prefix, setOf(SUFFIX)).assertFailure(), + "prefix '$prefix'", + ) + } + assertTrue(api.requests.isEmpty()) + } + + private fun paddedRange(lines: Int, hit: String?) = buildString { + repeat(lines) { append("%035X:0\n".format(it)) } + hit?.let { append(it).append('\n') } + } + + private class FakeRangeApi : Interceptor { + + val requests: MutableList = Collections.synchronizedList(mutableListOf()) + private val answers = ArrayDeque<(Request) -> Response>() + + fun answer(code: Int = 200, body: String = "", retryAfter: String? = null) { + answers += { request -> + Response.Builder() + .request(request) + .protocol(Protocol.HTTP_1_1) + .code(code) + .message("") + .body(body.toResponseBody()) + .apply { retryAfter?.let { header("Retry-After", it) } } + .build() + } + } + + fun fail() { + answers += { throw IOException("connection reset") } + } + + override fun intercept(chain: Interceptor.Chain): Response { + requests += chain.request() + return synchronized(answers) { answers.removeFirst() }(chain.request()) + } + } + + private companion object { + const val PREFIX = "5BAA6" + const val SUFFIX = "1E4C9B93F3F0682250B6CF8331B7EE68FD8" + } +} diff --git a/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/data/repository/FakeDataStore.kt b/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/data/repository/FakeDataStore.kt new file mode 100644 index 000000000..f6beb4535 --- /dev/null +++ b/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/data/repository/FakeDataStore.kt @@ -0,0 +1,15 @@ +package de.davis.keygo.feature.password_health.data.repository + +import androidx.datastore.core.DataStore +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow + +internal class FakeDataStore(initial: T) : DataStore { + + val state = MutableStateFlow(initial) + + override val data: Flow = state + + override suspend fun updateData(transform: suspend (t: T) -> T): T = + transform(state.value).also { state.value = it } +} diff --git a/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/data/repository/HealthNotificationStateRepositoryImplTest.kt b/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/data/repository/HealthNotificationStateRepositoryImplTest.kt new file mode 100644 index 000000000..468475b9d --- /dev/null +++ b/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/data/repository/HealthNotificationStateRepositoryImplTest.kt @@ -0,0 +1,62 @@ +package de.davis.keygo.feature.password_health.data.repository + +import de.davis.keygo.feature.password_health.data.local.model.ProtoHealthNotificationState +import de.davis.keygo.feature.password_health.domain.model.HealthNotificationState +import de.davis.keygo.feature.password_health.domain.model.HealthReminder +import de.davis.keygo.feature.password_health.domain.model.HealthSnapshot +import de.davis.keygo.feature.password_health.domain.model.VaultFingerprint +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.time.Instant + +class HealthNotificationStateRepositoryImplTest { + + private val dataStore = FakeDataStore(ProtoHealthNotificationState.getDefaultInstance()) + private val repository = HealthNotificationStateRepositoryImpl(dataStore) + + private val snapshot = HealthSnapshot(3, 1, VaultFingerprint(byteArrayOf(1, 2, 3))) + private val reminder = HealthReminder(3, 1, Instant.fromEpochMilliseconds(42), repeats = 1) + + @Test + fun startsEmpty() = runTest { + assertEquals(HealthNotificationState(), repository.get()) + } + + @Test + fun snapshotAndReminderRoundTrip() = runTest { + repository.setSnapshot(snapshot) + repository.setReminder(reminder) + + assertEquals(HealthNotificationState(snapshot, reminder), repository.get()) + } + + @Test + fun anEmptySnapshotIsStillASnapshot() = runTest { + val empty = HealthSnapshot(0, 0, VaultFingerprint.of(emptyList())) + + repository.setSnapshot(empty) + + assertEquals(empty, repository.get().snapshot) + } + + @Test + fun nullRemovesOnlyThatPart() = runTest { + repository.setSnapshot(snapshot) + repository.setReminder(reminder) + + repository.setReminder(null) + + assertEquals(HealthNotificationState(snapshot = snapshot), repository.get()) + } + + @Test + fun clearForgetsEverything() = runTest { + repository.setSnapshot(snapshot) + repository.setReminder(reminder) + + repository.clear() + + assertEquals(HealthNotificationState(), repository.get()) + } +} diff --git a/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/data/repository/HealthReportStoreRepositoryImplTest.kt b/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/data/repository/HealthReportStoreRepositoryImplTest.kt new file mode 100644 index 000000000..0486a0a79 --- /dev/null +++ b/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/data/repository/HealthReportStoreRepositoryImplTest.kt @@ -0,0 +1,289 @@ +package de.davis.keygo.feature.password_health.data.repository + +import com.google.protobuf.kotlin.toByteString +import de.davis.keygo.core.item.FakeItemRepository +import de.davis.keygo.core.item.domain.model.KeyInformation +import de.davis.keygo.core.item.domain.model.PasswordScore +import de.davis.keygo.core.security.FakeSession +import de.davis.keygo.core.security.crypto.BindingCryptographicScopeProvider +import de.davis.keygo.core.security.domain.SessionError +import de.davis.keygo.core.security.domain.crypto.model.CryptographicData +import de.davis.keygo.core.util.Result +import de.davis.keygo.core.util.assertFailure +import de.davis.keygo.core.util.assertSuccess +import de.davis.keygo.feature.password_health.data.local.model.ProtoHealthReportStore +import de.davis.keygo.feature.password_health.data.local.model.protoRelationFinding +import de.davis.keygo.feature.password_health.data.local.model.protoStoredHealthReport +import de.davis.keygo.feature.password_health.data.mapper.toCompressedByteArray +import de.davis.keygo.feature.password_health.data.mapper.toStoredHealthReport +import de.davis.keygo.feature.password_health.domain.model.CheckGap +import de.davis.keygo.feature.password_health.domain.model.CheckKind +import de.davis.keygo.feature.password_health.domain.model.GapReason +import de.davis.keygo.feature.password_health.domain.model.HealthFinding +import de.davis.keygo.feature.password_health.domain.model.HealthReportStoreError +import de.davis.keygo.feature.password_health.domain.model.RelationType +import de.davis.keygo.feature.password_health.domain.model.StoredHealthReport +import de.davis.keygo.feature.password_health.domain.report.breach +import de.davis.keygo.feature.password_health.domain.report.id +import de.davis.keygo.feature.password_health.domain.report.storedItem +import de.davis.keygo.rust.FakeItemManager +import de.davis.keygo.rust.FakeKeyWrapper +import de.davis.keygo.rust.FakeVaultManager +import kotlinx.coroutines.test.runTest +import java.io.ByteArrayOutputStream +import java.util.UUID +import java.util.zip.GZIPOutputStream +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue +import kotlin.time.Instant + +class HealthReportStoreRepositoryImplTest { + + private val session = FakeSession(startUnlocked = true) + private val provider = BindingCryptographicScopeProvider( + session = session, + itemRepository = FakeItemRepository(), + itemManager = FakeItemManager(), + keyWrapper = FakeKeyWrapper(), + vaultManager = FakeVaultManager(), + ) + private val dataStore = FakeDataStore(ProtoHealthReportStore.getDefaultInstance()) + private val repository = HealthReportStoreRepositoryImpl(dataStore, provider) + + private val report = StoredHealthReport( + items = listOf( + storedItem(id(0), score = PasswordScore.Weak, breach = breach(3, CHECKED_AT)), + storedItem(id(1), breach = breach(0, CHECKED_AT)), + storedItem(id(2), unreadable = true), + ), + relationalFindings = listOf(HealthFinding.Relation(setOf(id(0), id(1)), RelationType.Reused)), + gaps = mapOf(CheckKind.Breach to CheckGap(GapReason.Unreachable, setOf(id(2)))), + breachCheckEnabled = true, + ) + + @Test + fun nothingStoredYetIsNoReport() = runTest { + assertEquals(HealthReportStoreError.NoReportStored, repository.load().assertFailure()) + } + + @Test + fun aStoredReportLoadsBackUnchanged() = runTest { + repository.storeReport(report).assertSuccess() + + assertEquals(report, repository.load().assertSuccess()) + } + + @Test + fun theReportIsNotStoredInTheClear() = runTest { + repository.storeReport(report).assertSuccess() + + val stored = dataStore.state.value + assertTrue(stored.wrappedKey.size() > 0) + assertTrue(stored.iv.size() > 0) + assertTrue( + !stored.ciphertext.toByteArray().contentEquals(report.toCompressedByteArray()), + ) + } + + @Test + fun theLatestReportWins() = runTest { + val newer = report.copy(breachCheckEnabled = false, gaps = emptyMap()) + + repository.storeReport(report).assertSuccess() + repository.storeReport(newer).assertSuccess() + + assertEquals(newer, repository.load().assertSuccess()) + } + + @Test + fun laterReportsReuseTheFirstKey() = runTest { + repository.storeReport(report).assertSuccess() + val firstKey = dataStore.state.value.wrappedKey + + repository.storeReport(report.copy(breachCheckEnabled = false)).assertSuccess() + + assertEquals(firstKey, dataStore.state.value.wrappedKey) + } + + @Test + fun aLockedSessionCannotStoreAndLeavesTheStoreUntouched() = runTest { + val locked = HealthReportStoreRepositoryImpl( + dataStore, + BindingCryptographicScopeProvider( + session = FakeSession(startUnlocked = false), + itemRepository = FakeItemRepository(), + itemManager = FakeItemManager(), + keyWrapper = FakeKeyWrapper(), + vaultManager = FakeVaultManager(), + ), + ) + + assertEquals(HealthReportStoreError.CryptoFailed, locked.storeReport(report).assertFailure()) + assertEquals(ProtoHealthReportStore.getDefaultInstance(), dataStore.state.value) + } + + @Test + fun aKeyThatWillNotUnwrapIsACryptoFailureOnStore() = runTest { + repository.storeReport(report).assertSuccess() + val before = dataStore.state.value + session.unwrapVaultKeyFailure = SessionError.Locked + + assertEquals(HealthReportStoreError.CryptoFailed, repository.storeReport(report).assertFailure()) + assertEquals(before, dataStore.state.value) + } + + @Test + fun aKeyThatWillNotUnwrapIsACryptoFailureOnLoad() = runTest { + repository.storeReport(report).assertSuccess() + session.unwrapVaultKeyFailure = SessionError.Locked + + assertEquals(HealthReportStoreError.CryptoFailed, repository.load().assertFailure()) + } + + @Test + fun aKeyWithoutAReportIsNoReport() = runTest { + repository.storeReport(report).assertSuccess() + dataStore.state.value = dataStore.state.value.toBuilder().clearCiphertext().build() + + assertEquals(HealthReportStoreError.NoReportStored, repository.load().assertFailure()) + } + + @Test + fun aReportWithoutAKeyIsNoReport() = runTest { + repository.storeReport(report).assertSuccess() + dataStore.state.value = dataStore.state.value.toBuilder().clearWrappedKey().build() + + assertEquals(HealthReportStoreError.NoReportStored, repository.load().assertFailure()) + } + + @Test + fun aReportFromAnotherAlgorithmVersionIsCorrupted() = runTest { + repository.storeReport(report).assertSuccess() + seal( + protoStoredHealthReport { + algorithmVersion = StoredHealthReport.ALGORITHM_VERSION + 1 + }.toByteArray().gzip(), + ) + + assertEquals(HealthReportStoreError.CorruptedReport, repository.load().assertFailure()) + } + + @Test + fun theReportIsSealedUnderItsFixedNamespaceAndLabel() = runTest { + repository.storeReport(report).assertSuccess() + val stored = dataStore.state.value + + val opened = provider.accountScope( + namespace = REPORT_NAMESPACE, + wrapped = stored.wrappedKey(), + ) { + CryptographicData(stored.ciphertext.toByteArray(), stored.iv.toByteArray()) + .decrypt(REPORT_LABEL) + } + + assertEquals(report, (opened as Result.Success).success.toStoredHealthReport()) + } + + @Test + fun theKeyIsBoundToTheReportNamespace() = runTest { + repository.storeReport(report).assertSuccess() + + val opened = provider.accountScope( + namespace = UUID.randomUUID(), + wrapped = dataStore.state.value.wrappedKey(), + ) { } + + assertTrue(opened is Result.Failure) + } + + @Test + fun aTamperedCiphertextIsCorrupted() = runTest { + repository.storeReport(report).assertSuccess() + val stored = dataStore.state.value + val tampered = stored.ciphertext.toByteArray().also { it[0] = (it[0] + 1).toByte() } + dataStore.state.value = stored.toBuilder().setCiphertext(tampered.toByteString()).build() + + assertEquals(HealthReportStoreError.CorruptedReport, repository.load().assertFailure()) + } + + @Test + fun aSwappedIvIsCorrupted() = runTest { + repository.storeReport(report).assertSuccess() + val stored = dataStore.state.value + dataStore.state.value = stored.toBuilder() + .setIv(ByteArray(stored.iv.size()).toByteString()) + .build() + + assertEquals(HealthReportStoreError.CorruptedReport, repository.load().assertFailure()) + } + + @Test + fun aReportThatIsNotGzipIsCorrupted() = runTest { + repository.storeReport(report).assertSuccess() + seal(byteArrayOf(1, 2, 3)) + + assertEquals(HealthReportStoreError.CorruptedReport, repository.load().assertFailure()) + } + + @Test + fun aReportThatIsNotAProtoIsCorrupted() = runTest { + repository.storeReport(report).assertSuccess() + seal(byteArrayOf(0x0a, 0x7f, 0x01).gzip()) + + assertEquals(HealthReportStoreError.CorruptedReport, repository.load().assertFailure()) + } + + @Test + fun aReportPointingPastItsItemsIsCorrupted() = runTest { + repository.storeReport(report).assertSuccess() + seal( + protoStoredHealthReport { + algorithmVersion = StoredHealthReport.ALGORITHM_VERSION + relationFinding += protoRelationFinding { itemRefs += listOf(0, 1) } + }.toByteArray().gzip(), + ) + + assertEquals(HealthReportStoreError.CorruptedReport, repository.load().assertFailure()) + } + + @Test + fun aCorruptedReportCanBeOverwritten() = runTest { + repository.storeReport(report).assertSuccess() + seal(byteArrayOf(1, 2, 3)) + + repository.storeReport(report).assertSuccess() + + assertEquals(report, repository.load().assertSuccess()) + } + + /** Replaces the stored report with [plaintext], sealed the way the repository seals one. */ + private suspend fun seal(plaintext: ByteArray) { + val stored = dataStore.state.value + val sealed = provider.accountScope(REPORT_NAMESPACE, stored.wrappedKey()) { + plaintext.encrypt(REPORT_LABEL) + }.assertSuccess() + + dataStore.state.value = stored.toBuilder() + .setCiphertext(sealed.data.toByteString()) + .setIv(sealed.iv.toByteString()) + .build() + } + + private fun ProtoHealthReportStore.wrappedKey() = KeyInformation( + wrappedKey = wrappedKey.toByteArray(), + keyNonce = wrappedKeyNonce.toByteArray(), + ) + + private fun ByteArray.gzip(): ByteArray = ByteArrayOutputStream().also { bos -> + GZIPOutputStream(bos).use { it.write(this) } + }.toByteArray() + + private companion object { + val CHECKED_AT: Instant = Instant.fromEpochMilliseconds(1_700_000_000_000) + + // Pinned on purpose: changing either makes every stored report undecryptable. + val REPORT_NAMESPACE: UUID = UUID.fromString("39050172-ce6d-4f4d-8787-6e12ab44eb0e") + const val REPORT_LABEL = "password_health_report" + } +} diff --git a/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/data/repository/HealthSettingsRepositoryImplTest.kt b/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/data/repository/HealthSettingsRepositoryImplTest.kt new file mode 100644 index 000000000..89829f5fa --- /dev/null +++ b/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/data/repository/HealthSettingsRepositoryImplTest.kt @@ -0,0 +1,82 @@ +package de.davis.keygo.feature.password_health.data.repository + +import de.davis.keygo.feature.password_health.data.local.model.ProtoHealthSettings +import de.davis.keygo.feature.password_health.domain.model.HealthSettings +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.take +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals + +class HealthSettingsRepositoryImplTest { + + private val dataStore = FakeDataStore(ProtoHealthSettings.getDefaultInstance()) + private val repository = HealthSettingsRepositoryImpl(dataStore) + + @Test + fun everythingIsOffUntilTurnedOn() = runTest { + assertEquals(settings(), repository.getBreachCheckState()) + } + + @Test + fun turningTheBreachCheckOnIsRemembered() = runTest { + repository.setBreachEnabled(true) + + assertEquals(settings(breaches = true), repository.getBreachCheckState()) + assertEquals(true, dataStore.state.value.breachesEnabled) + } + + @Test + fun turningTheBreachCheckOffAgainIsRemembered() = runTest { + repository.setBreachEnabled(true) + repository.setBreachEnabled(false) + + assertEquals(settings(), repository.getBreachCheckState()) + } + + @Test + fun turningNotificationsOnIsRemembered() = runTest { + repository.setNotificationEnabled(true) + + assertEquals(settings(notifications = true), repository.getBreachCheckState()) + assertEquals(true, dataStore.state.value.notificationsEnabled) + } + + @Test + fun eachSettingLeavesTheOtherAlone() = runTest { + repository.setBreachEnabled(true) + repository.setNotificationEnabled(true) + repository.setBreachEnabled(false) + + assertEquals(settings(notifications = true), repository.getBreachCheckState()) + } + + @Test + fun observersSeeEveryChange() = runTest { + val seen = mutableListOf() + backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { + repository.observeBreachCheckState().take(3).toList(seen) + } + + repository.setBreachEnabled(true) + repository.setBreachEnabled(false) + + assertEquals( + listOf(false, true, false).map { settings(breaches = it) }, + seen, + ) + } + + @Test + fun observingStartsFromTheStoredValue() = runTest { + dataStore.state.value = ProtoHealthSettings.newBuilder().setBreachesEnabled(true).build() + + assertEquals(settings(breaches = true), repository.observeBreachCheckState().first()) + } + + private fun settings(breaches: Boolean = false, notifications: Boolean = false) = + HealthSettings(breachesEnabled = breaches, notificationsEnabled = notifications) +} diff --git a/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/domain/PasswordHealthAttentionTest.kt b/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/domain/PasswordHealthAttentionTest.kt new file mode 100644 index 000000000..7e2bd20f2 --- /dev/null +++ b/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/domain/PasswordHealthAttentionTest.kt @@ -0,0 +1,35 @@ +package de.davis.keygo.feature.password_health.domain + +import de.davis.keygo.core.security.FakeSession +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.cancel +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertEquals + +class PasswordHealthAttentionTest { + + private val scope = CoroutineScope(Dispatchers.Unconfined) + + @AfterTest + fun tearDown() = scope.cancel() + + @Test + fun anUnlockedSessionShowsTheCount() { + val attention = PasswordHealthAttention(FakeSession(startUnlocked = true), scope) + + attention.update(3) + + assertEquals(3, attention.needsAttention.value) + } + + @Test + fun aCountPublishedWhileLockedStaysHidden() { + val attention = PasswordHealthAttention(FakeSession(), scope) + + attention.update(3) + + assertEquals(0, attention.needsAttention.value) + } +} diff --git a/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/domain/UnlockHealthScanTest.kt b/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/domain/UnlockHealthScanTest.kt new file mode 100644 index 000000000..014da2016 --- /dev/null +++ b/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/domain/UnlockHealthScanTest.kt @@ -0,0 +1,181 @@ +package de.davis.keygo.feature.password_health.domain + +import de.davis.keygo.core.item.FakeItemRepository +import de.davis.keygo.core.item.FakeLoginRepository +import de.davis.keygo.core.item.domain.model.ItemKeyEnvelope +import de.davis.keygo.core.item.domain.model.KeyInformation +import de.davis.keygo.core.item.domain.model.PasswordScore +import de.davis.keygo.core.security.FakeSession +import de.davis.keygo.core.security.crypto.FakeCryptographicScopeProvider +import de.davis.keygo.core.util.Result +import de.davis.keygo.feature.password_health.FakeHealthCheckNotifierScheduler +import de.davis.keygo.feature.password_health.FakeHealthNotificationStateRepository +import de.davis.keygo.feature.password_health.FakeHealthSettingsRepository +import de.davis.keygo.feature.password_health.data.LoginFingerprinterImpl +import de.davis.keygo.feature.password_health.domain.checker.PasswordHealthChecker +import de.davis.keygo.feature.password_health.domain.checker.WeakPasswordChecker +import de.davis.keygo.feature.password_health.domain.model.CheckKind +import de.davis.keygo.feature.password_health.domain.model.CheckOutcome +import de.davis.keygo.feature.password_health.domain.model.HealthReportStoreError +import de.davis.keygo.feature.password_health.domain.model.PasswordCandidate +import de.davis.keygo.feature.password_health.domain.model.StoredHealthReport +import de.davis.keygo.feature.password_health.domain.report.HealthReportAssembler +import de.davis.keygo.feature.password_health.domain.report.HealthReportScanner +import de.davis.keygo.feature.password_health.domain.report.VAULT_ID +import de.davis.keygo.feature.password_health.domain.report.id +import de.davis.keygo.feature.password_health.domain.report.login +import de.davis.keygo.feature.password_health.domain.repository.HealthReportStoreRepository +import de.davis.keygo.feature.password_health.domain.usecase.PasswordHealthReportUseCase +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.awaitCancellation +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.time.Duration.Companion.seconds + +// The use case does its work on Dispatchers.Default, which runTest's scheduler does not drive, so +// these run on real threads and wait on the observable count instead of on virtual time. +class UnlockHealthScanTest { + + private val session = FakeSession() + private val loginRepository = FakeLoginRepository() + private val itemRepository = FakeItemRepository(loginRepository) + private val breachState = FakeHealthSettingsRepository(breachesEnabled = false) + private val appScope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + private val attention = PasswordHealthAttention(session, appScope) + + @AfterTest + fun tearDown() = appScope.cancel() + + @Test + fun unlockingScansTheVaultAndPublishesTheCount() = runBlocking(Dispatchers.Default) { + seedWeak(0, 1) + startWith() + + unlock() + + awaitCount(2) + } + + @Test + fun lockingClearsTheCount() = runBlocking(Dispatchers.Default) { + seedWeak(0, 1) + startWith() + unlock() + awaitCount(2) + + session.endSession() + + awaitCount(0) + } + + @Test + fun everyUnlockScansAgain() = runBlocking(Dispatchers.Default) { + seedWeak(0) + startWith() + unlock() + awaitCount(1) + session.endSession() + awaitCount(0) + seedWeak(1) + + unlock() + + awaitCount(2) + } + + @Test + fun lockingCancelsAScanInFlight() = runBlocking(Dispatchers.Default) { + seedWeak(0) + val blocked = Blocking() + startWith(listOf(blocked)) + unlock() + withTimeout(TIMEOUT) { blocked.entered.await() } + + session.endSession() + + withTimeout(TIMEOUT) { blocked.cancelled.await() } + } + + private fun startWith( + checkers: List = listOf(WeakPasswordChecker()), + ) = UnlockHealthScan( + session = session, + passwordHealth = PasswordHealthReportUseCase( + loginRepository = loginRepository, + loginFingerprinter = LoginFingerprinterImpl(), + healthReportStoreRepository = InMemoryStore(), + healthSettingsRepository = breachState, + scanner = HealthReportScanner(FakeCryptographicScopeProvider(itemRepository), checkers), + assembler = HealthReportAssembler(), + attention = attention, + healthNotificationStateRepository = FakeHealthNotificationStateRepository(), + healthCheckNotifierScheduler = FakeHealthCheckNotifierScheduler(), + ), + attention = attention, + appScope = appScope, + ) + + private suspend fun unlock() { + session.unlockWithArk(ByteArray(ARK_SIZE)) + } + + private suspend fun awaitCount(count: Int) { + withTimeout(TIMEOUT) { attention.needsAttention.first { it == count } } + } + + private fun seedWeak(vararg ns: Int) = ns.forEach { n -> + itemRepository.seedEnvelope( + ItemKeyEnvelope( + vaultId = VAULT_ID, + itemId = id(n), + itemKeyInformation = KeyInformation(byteArrayOf(), byteArrayOf()), + vaultKeyInformation = KeyInformation(byteArrayOf(), byteArrayOf()), + ), + ) + loginRepository.seed(login(id(n), score = PasswordScore.Weak)) + } + + private class Blocking : PasswordHealthChecker { + + override val type = CheckKind.Strength + + val entered = CompletableDeferred() + val cancelled = CompletableDeferred() + + override suspend fun check(candidates: List): CheckOutcome { + entered.complete(Unit) + try { + awaitCancellation() + } catch (e: CancellationException) { + cancelled.complete(Unit) + throw e + } + } + } + + private class InMemoryStore : HealthReportStoreRepository { + private var stored: StoredHealthReport? = null + + override suspend fun storeReport(report: StoredHealthReport): Result { + stored = report + return Result.Success(Unit) + } + + override suspend fun load(): Result = + stored?.let { Result.Success(it) } + ?: Result.Failure(HealthReportStoreError.NoReportStored) + } + + private companion object { + val TIMEOUT = 5.seconds + const val ARK_SIZE = 32 + } +} diff --git a/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/domain/checker/BreachedPasswordCheckTest.kt b/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/domain/checker/BreachedPasswordCheckTest.kt new file mode 100644 index 000000000..4ef687ff3 --- /dev/null +++ b/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/domain/checker/BreachedPasswordCheckTest.kt @@ -0,0 +1,215 @@ +package de.davis.keygo.feature.password_health.domain.checker + +import de.davis.keygo.feature.password_health.FakeBreachedRepository +import de.davis.keygo.feature.password_health.FakeConnectivityRepository +import de.davis.keygo.feature.password_health.FakeHealthSettingsRepository +import de.davis.keygo.feature.password_health.domain.model.BreachedError +import de.davis.keygo.feature.password_health.domain.model.CheckGap +import de.davis.keygo.feature.password_health.domain.model.CheckKind +import de.davis.keygo.feature.password_health.domain.model.GapReason +import de.davis.keygo.feature.password_health.domain.model.HealthFinding +import de.davis.keygo.feature.password_health.domain.model.ItemIssue +import kotlinx.coroutines.test.runTest +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import java.util.UUID +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +// Robolectric only so the gap warnings can reach android.util.Log. +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34]) +class BreachedPasswordCheckTest { + + private val breached = FakeBreachedRepository() + private val state = FakeHealthSettingsRepository(breachesEnabled = true) + private val connectivity = FakeConnectivityRepository(online = true) + private val checker = BreachedPasswordCheck(breached, state, connectivity) + + @Test + fun reportsAsTheBreachCheck() { + assertEquals(CheckKind.Breach, checker.type) + } + + @Test + fun asksForTheRangeOfTheUpperCaseSha1() = runTest { + checker.check(candidates("password")) + + assertEquals( + listOf( + FakeBreachedRepository.Call( + "5BAA6", + setOf("1E4C9B93F3F0682250B6CF8331B7EE68FD8") + ) + ), + breached.calls, + ) + } + + @Test + fun hashesThePasswordAsUtf8() = runTest { + checker.check(candidates("pässwörd")) + + assertEquals( + FakeBreachedRepository.Call("F517D", setOf("DF1D32A112FF1AD55C66D1B12CB38E7E8F7")), + breached.calls.single(), + ) + } + + @Test + fun aBreachedPasswordIsFlaggedWithItsOccurrences() = runTest { + breached.breaches = mapOf(PASSWORD to 42) + + val outcome = checker.check(candidates("password", "hunter2")) + + assertEquals( + listOf(HealthFinding.Item(id(0), ItemIssue.Breached(occurrences = 42))), + outcome.findings, + ) + assertNull(outcome.gap) + } + + @Test + fun aCleanVaultHasNoFindingsAndNoGap() = runTest { + val outcome = checker.check(candidates("password", "hunter2")) + + assertTrue(outcome.findings.isEmpty()) + assertNull(outcome.gap) + } + + @Test + fun everyLoginSharingABreachedPasswordIsFlagged() = runTest { + breached.breaches = mapOf(PASSWORD to 7) + + val outcome = checker.check(candidates("password", "password")) + + assertEquals( + setOf( + HealthFinding.Item(id(0), ItemIssue.Breached(7)), + HealthFinding.Item(id(1), ItemIssue.Breached(7)), + ), + outcome.findings.toSet(), + ) + } + + @Test + fun aSharedPasswordIsLookedUpOnce() = runTest { + checker.check(candidates("password", "password", "password")) + + assertEquals(1, breached.calls.size) + assertEquals(1, breached.calls.single().suffixes.size) + } + + @Test + fun eachRangeIsAskedForSeparately() = runTest { + checker.check(candidates("password", "hunter2")) + + assertEquals(setOf("5BAA6", "F3BBB"), breached.calls.mapTo(mutableSetOf()) { it.prefix }) + } + + @Test + fun nothingToCheckAsksNobody() = runTest { + val outcome = checker.check(emptyList()) + + assertTrue(breached.calls.isEmpty()) + assertTrue(outcome.findings.isEmpty()) + assertNull(outcome.gap) + } + + @Test + fun aDisabledCheckSkipsEveryPasswordWithoutAsking() = runTest { + state.setBreachEnabled(false) + + val outcome = checker.check(candidates("password", "hunter2")) + + assertTrue(breached.calls.isEmpty()) + assertTrue(outcome.findings.isEmpty()) + assertEquals(CheckGap(GapReason.Disabled, setOf(id(0), id(1))), outcome.gap) + } + + @Test + fun noInternetSkipsEveryPasswordWithoutAsking() = runTest { + connectivity.online = false + + val outcome = checker.check(candidates("password", "hunter2")) + + assertTrue(breached.calls.isEmpty()) + assertEquals(CheckGap(GapReason.Unreachable, setOf(id(0), id(1))), outcome.gap) + } + + @Test + fun anUnreachableRangeLeavesItsPasswordsUnchecked() = runTest { + breached.failures["5BAA6"] = BreachedError.Unreachable + + val outcome = checker.check(candidates("password", "hunter2")) + + assertEquals(CheckGap(GapReason.Unreachable, setOf(id(0))), outcome.gap) + } + + @Test + fun aFailedApiAnswerIsAFailedGap() = runTest { + breached.failures["5BAA6"] = BreachedError.ApiFailed + + val outcome = checker.check(candidates("password")) + + assertEquals(CheckGap(GapReason.Failed, setOf(id(0))), outcome.gap) + } + + @Test + fun anInvalidPrefixIsAFailedGap() = runTest { + breached.failures["5BAA6"] = BreachedError.InvalidPrefix + + val outcome = checker.check(candidates("password")) + + assertEquals(CheckGap(GapReason.Failed, setOf(id(0))), outcome.gap) + } + + @Test + fun unreachableWinsOverAFailureInTheSameRun() = runTest { + breached.failures["5BAA6"] = BreachedError.ApiFailed + breached.failures["F3BBB"] = BreachedError.Unreachable + + val outcome = checker.check(candidates("password", "hunter2")) + + assertEquals(CheckGap(GapReason.Unreachable, setOf(id(0), id(1))), outcome.gap) + } + + @Test + fun aFailedRangeKeepsTheFindingsOfTheRangesThatAnswered() = runTest { + breached.breaches = mapOf(HUNTER2 to 3) + breached.failures["5BAA6"] = BreachedError.Unreachable + + val outcome = checker.check(candidates("password", "hunter2")) + + assertEquals(listOf(HealthFinding.Item(id(1), ItemIssue.Breached(3))), outcome.findings) + assertEquals(CheckGap(GapReason.Unreachable, setOf(id(0))), outcome.gap) + } + + @Test + fun aFailedRangeLeavesEveryLoginSharingItsPasswordUnchecked() = runTest { + breached.failures["5BAA6"] = BreachedError.ApiFailed + + val outcome = checker.check(candidates("password", "password")) + + assertEquals(CheckGap(GapReason.Failed, setOf(id(0), id(1))), outcome.gap) + } + + @Test + fun thePasswordsAreLeftForTheCallerToWipe() = runTest { + val candidates = candidates("password") + + checker.check(candidates) + + assertEquals("password", candidates.single().password.concatToString()) + } + + private fun id(n: Int) = UUID(0L, n.toLong()) + + private companion object { + const val PASSWORD = "5BAA61E4C9B93F3F0682250B6CF8331B7EE68FD8" + const val HUNTER2 = "F3BBBD66A63D4BF1747940578EC3D0103530E21D" + } +} diff --git a/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/domain/checker/CheckerFixtures.kt b/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/domain/checker/CheckerFixtures.kt new file mode 100644 index 000000000..8e5f3830f --- /dev/null +++ b/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/domain/checker/CheckerFixtures.kt @@ -0,0 +1,32 @@ +package de.davis.keygo.feature.password_health.domain.checker + +import de.davis.keygo.core.item.domain.model.PasswordScore +import de.davis.keygo.feature.password_health.domain.model.HealthFinding +import de.davis.keygo.feature.password_health.domain.model.PasswordCandidate +import java.util.UUID + +/** Candidates whose ids carry their position, so a finding can be read back as text. */ +internal fun candidates(passwords: List): List = + passwords.mapIndexed { index, password -> + PasswordCandidate( + id = UUID(0L, index.toLong()), + score = PasswordScore.Moderate, + password = password.toCharArray(), + ) + } + +internal fun candidates(vararg passwords: String) = candidates(passwords.toList()) + +/** The flagged groups, as the passwords themselves, so a failure reads plainly. */ +internal suspend fun flag(passwords: List): Set> { + val candidates = candidates(passwords) + val byId = candidates.associateBy({ it.id }, { passwords[it.id.leastSignificantBits.toInt()] }) + return SimilarPasswordChecker().check(candidates) + .findings + .filterIsInstance() + .mapTo(mutableSetOf()) { finding -> + finding.relatedItemIds.mapTo(mutableSetOf()) { byId.getValue(it) } + } +} + +internal suspend fun flag(vararg passwords: String) = flag(passwords.toList()) diff --git a/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/domain/checker/PassphraseNoiseTest.kt b/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/domain/checker/PassphraseNoiseTest.kt new file mode 100644 index 000000000..81240b81f --- /dev/null +++ b/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/domain/checker/PassphraseNoiseTest.kt @@ -0,0 +1,115 @@ +package de.davis.keygo.feature.password_health.domain.checker + +import kotlinx.coroutines.test.runTest +import kotlin.random.Random +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue +import kotlin.time.Duration.Companion.minutes + +class PassphraseNoiseTest { + + private val words = SimilarityCorpus.passphraseWords + + @Test + fun oneStyleThroughoutAVaultRaisesNothing() = runTest(timeout = 5.minutes) { + Style.entries.forEach { style -> + val vault = List(250) { style.build(words, Random(it.toLong() + style.ordinal * 1000)) } + .distinct() + + val flagged = flag(vault) + + assertEquals( + emptySet(), + flagged, + "style ${style.name} raised ${flagged.size} groups among independent passphrases", + ) + } + } + + @Test + fun aNarrowPersonalVocabularyStaysNearlyQuiet() = runTest(timeout = 5.minutes) { + val narrow = words.take(40) + val vault = List(250) { Style.HyphenLower.build(narrow, Random(it.toLong())) }.distinct() + + val flagged = flag(vault) + + assertTrue( + flagged.size <= vault.size / 25, + "${flagged.size} groups among ${vault.size} passphrases from a 40 word vocabulary: $flagged", + ) + } + + @Test + fun sharingOneWordIsNotEnough() = runTest { + assertEquals( + emptySet(), + flag("correct-horse-battery-staple", "correct-otter-lantern-puzzle"), + ) + } + + @Test + fun sharingTwoLeadingWordsIsNotEnough() = runTest { + assertEquals( + emptySet(), + flag("correct-horse-battery-staple", "correct-horse-lantern-puzzle"), + ) + } + + @Test + fun sharingTwoLeadingWordsIsNotEnoughInCapitals() = runTest { + assertEquals( + emptySet(), + flag("MAPLE-CEDAR-WILLOW-ASPEN", "MAPLE-CEDAR-SPRUCE-JUNIPER"), + ) + } + + @Test + fun sharingTwoLeadingShortWordsIsNotEnough() = runTest { + assertEquals(emptySet(), flag("Oak-Elk-Ivy-Fig-Owl", "Oak-Elk-Ant-Bee-Cow")) + } + + @Test + fun sharingEveryWordButOneIsStillReported() = runTest { + val flagged = flag("correct-horse-battery-staple", "correct-horse-battery-puzzle") + + assertEquals(1, flagged.size) + } + + @Test + fun theSamePassphraseWithADigitAppendedIsStillReported() = runTest { + val flagged = flag("correct-horse-battery-staple", "correct-horse-battery-staple7") + + assertEquals(1, flagged.size) + } + + @Test + fun theSamePassphraseRepunctuatedIsStillReported() = runTest { + val flagged = flag("correct-horse-battery-staple", "correct.horse.battery.staple") + + assertEquals(1, flagged.size) + } + + private enum class Style( + private val count: IntRange, + private val separator: Char, + private val capitalise: Boolean, + private val trailingDigits: Int, + ) { + HyphenLower(3..3, '-', false, 0), + HyphenLowerLong(6..6, '-', false, 0), + DotCapitalised(4..4, '.', true, 0), + UnderscoreWithDigits(4..4, '_', false, 2), + SpacedLower(5..5, ' ', false, 0), + ; + + fun build(words: List, random: Random): String { + val chosen = List(random.nextInt(count.first, count.last + 1)) { + val word = words.random(random) + if (capitalise) word.replaceFirstChar { char -> char.uppercase() } else word + } + val digits = (1..trailingDigits).joinToString("") { random.nextInt(10).toString() } + return chosen.joinToString(separator.toString()) + digits + } + } +} diff --git a/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/domain/checker/ReusePasswordCheckTest.kt b/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/domain/checker/ReusePasswordCheckTest.kt new file mode 100644 index 000000000..d7dab9954 --- /dev/null +++ b/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/domain/checker/ReusePasswordCheckTest.kt @@ -0,0 +1,94 @@ +package de.davis.keygo.feature.password_health.domain.checker + +import de.davis.keygo.feature.password_health.domain.model.CheckKind +import de.davis.keygo.feature.password_health.domain.model.HealthFinding +import de.davis.keygo.feature.password_health.domain.model.RelationType +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class ReusePasswordCheckTest { + + private val checker = ReusePasswordCheck() + + @Test + fun reportsAsTheReuseCheck() { + assertEquals(CheckKind.Reuse, checker.type) + } + + @Test + fun flagsTwoLoginsSharingAPassword() = runTest { + assertEquals(setOf(setOf("hunter2")), reused("hunter2", "hunter2", "other-password")) + } + + @Test + fun everyLoginSharingThePasswordIsInTheOneFinding() = runTest { + val candidates = candidates("same", "same", "same") + + val findings = ReusePasswordCheck().check(candidates).findings + + assertEquals( + listOf( + HealthFinding.Relation( + relatedItemIds = candidates.mapTo(mutableSetOf()) { it.id }, + type = RelationType.Reused, + ), + ), + findings, + ) + } + + @Test + fun separateSharedPasswordsAreSeparateFindings() = runTest { + assertEquals(setOf(setOf("alpha"), setOf("beta")), reused("alpha", "beta", "alpha", "beta")) + } + + @Test + fun uniquePasswordsAreNotReused() = runTest { + assertTrue(reused("one", "two", "three").isEmpty()) + } + + @Test + fun passwordsDifferingOnlyInCaseAreNotReused() = runTest { + assertTrue(reused("Password", "password").isEmpty()) + } + + @Test + fun passwordsDifferingOnlyInTrailingWhitespaceAreNotReused() = runTest { + assertTrue(reused("password", "password ").isEmpty()) + } + + @Test + fun sharedNonAsciiPasswordsAreReused() = runTest { + assertEquals(setOf(setOf("pässwörd🔑")), reused("pässwörd🔑", "pässwörd🔑")) + } + + @Test + fun aSinglePasswordIsNotReused() = runTest { + assertTrue(reused("lonely").isEmpty()) + } + + @Test + fun nothingToCheckFindsNothing() = runTest { + val outcome = checker.check(emptyList()) + + assertTrue(outcome.findings.isEmpty()) + assertNull(outcome.gap) + } + + /** The reused groups, as the passwords themselves collapsed to one per group. */ + private suspend fun reused(vararg passwords: String): Set> { + val candidates = candidates(*passwords) + val byId = candidates.associate { it.id to it.password.concatToString() } + + return checker.check(candidates).findings + .map { assertRelation(it) } + .mapTo(mutableSetOf()) { ids -> ids.mapTo(mutableSetOf()) { byId.getValue(it) } } + } + + private fun assertRelation(finding: HealthFinding) = + (finding as HealthFinding.Relation).also { assertEquals(RelationType.Reused, it.type) } + .relatedItemIds +} diff --git a/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/domain/checker/SimilarPasswordCheckerBenchmark.kt b/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/domain/checker/SimilarPasswordCheckerBenchmark.kt new file mode 100644 index 000000000..784ac2e4e --- /dev/null +++ b/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/domain/checker/SimilarPasswordCheckerBenchmark.kt @@ -0,0 +1,478 @@ +package de.davis.keygo.feature.password_health.domain.checker + +import de.davis.keygo.core.item.domain.alias.ItemId +import de.davis.keygo.feature.password_health.domain.model.HealthFinding +import kotlinx.coroutines.test.runTest +import java.io.File +import java.lang.management.ManagementFactory +import kotlin.math.abs +import kotlin.math.max +import kotlin.math.min +import kotlin.system.measureNanoTime +import kotlin.test.Test +import kotlin.time.Duration.Companion.hours + +/** + * Measures [SimilarPasswordChecker] against labelled corpora: how many variations it + * finds, how many unrelated passwords it joins, and what the pairwise sweep costs as a + * vault grows. + * + * Not a unit test. Everything is read from the environment so the default run stays cheap: + * + * SIMILARITY_VAULTS=4000 SIMILARITY_POOL_SIZES=5000 \ + * SIMILARITY_SCALING_SIZES=1000,2000,4000,8000,16000,32000 \ + * ./gradlew :feature:password-health:testDebugUnitTest \ + * --tests '*SimilarPasswordCheckerBenchmark*' + * + * The report lands in `build/reports/similarity/report.txt`. + */ +class SimilarPasswordCheckerBenchmark { + + private val report = StringBuilder() + + @Test + fun measure() = runTest(timeout = 6.hours) { + val vaultCount = env("SIMILARITY_VAULTS")?.toIntOrNull() ?: 300 + val poolSizes = sizes("SIMILARITY_POOL_SIZES", default = listOf(2_000)) + val scalingSizes = sizes("SIMILARITY_SCALING_SIZES", default = listOf(500, 1_000, 2_000)) + + line("SimilarPasswordChecker measurement") + line( + "java=${System.getProperty("java.version")} cores=${cores()} " + + "maxHeap=${Runtime.getRuntime().maxMemory() / (1024 * 1024)}MB" + ) + line("") + + // Warm the JIT so the first timed run is not measuring the interpreter. + SimilarPasswordChecker().check(SimilarityCorpus.singleVault(400, seed = 1L).candidates()) + + vaultSimulation(vaultCount) + flush() + poolSizes.forEach { pool(it); flush() } + + line("== scaling: one vault of n entries ==") + line( + "%10s %14s %14s %12s %12s %12s %10s".format( + "n", "pairs", "elapsed_ms", "pairs/ms", "findings", "alloc_MB", "gc_ms", + ), + ) + scalingSizes.forEach { scale(it); flush() } + + println(report) + println("report written to ${reportFile().absolutePath}") + } + + private fun reportFile() = + File(env("SIMILARITY_REPORT_DIR") ?: "build/reports/similarity", "report.txt") + + /** Written after every section so a run that dies late still leaves its results. */ + private fun flush() { + val out = reportFile() + out.parentFile?.mkdirs() + out.writeText(report.toString()) + } + + /** + * The headline measurement: many separate vaults, each with one person's small + * vocabulary and habits. Rates are aggregated over every vault, and the per-vault + * spread says what a single user actually sees. + */ + private suspend fun vaultSimulation(count: Int) { + val stats = Stats() + var elapsed = 0L + SimilarityCorpus.vaults(count).forEach { corpus -> + val candidates = corpus.candidates() + var findings: List + elapsed += measureNanoTime { + findings = SimilarPasswordChecker().check(candidates).findings + } + stats.accumulate(corpus, edgesOf(findings), findings) + } + + line("== accuracy: $count simulated vaults ==") + line("passwords ${stats.items}") + line( + "vault size min ${stats.vaultSizes.min()}, median " + + "${stats.vaultSizes.sorted()[stats.vaultSizes.size / 2]}, max ${stats.vaultSizes.max()}" + ) + line("total elapsed ${elapsed / 1_000_000} ms") + line("") + stats.write() + + val quiet = stats.falsePositivesPerVault.count { it == 0 } + val sortedFp = stats.falsePositivesPerVault.sorted() + line("-- what one user sees --") + line("vaults with no false pair $quiet / $count ${pct(ratio(quiet, count))}") + line( + "false pairs per vault median ${sortedFp[sortedFp.size / 2]}, " + + "p90 ${sortedFp[(sortedFp.size * 0.9).toInt()]}, max ${sortedFp.last()}" + ) + val sortedGroups = stats.contaminatedPerVault.sorted() + line( + "wrong groups per vault median ${sortedGroups[sortedGroups.size / 2]}, " + + "p90 ${sortedGroups[(sortedGroups.size * 0.9).toInt()]}, max ${sortedGroups.last()}" + ) + line("") + } + + /** One undifferentiated pool, for a precision figure that does not depend on vault size. */ + private suspend fun pool(size: Int) { + val corpus = SimilarityCorpus.singleVault(size) + val candidates = corpus.candidates() + val runtime = Runtime.getRuntime() + System.gc() + val before = runtime.totalMemory() - runtime.freeMemory() + var findings: List + val elapsed = + measureNanoTime { findings = SimilarPasswordChecker().check(candidates).findings } + val after = runtime.totalMemory() - runtime.freeMemory() + + val stats = Stats() + stats.accumulate(corpus, edgesOf(findings), findings) + + line("== accuracy: one pool of $size, wide vocabulary ==") + line("elapsed ${elapsed / 1_000_000} ms") + line("heap delta ${(after - before) / (1024 * 1024)} MB") + line("") + stats.write() + } + + private suspend fun scale(size: Int) { + val pairs = size.toLong() * (size - 1) / 2 + try { + val candidates = SimilarityCorpus.singleVault(size).candidates() + var findings = 0 + val allocatedBefore = allocatedBytes() + val gcBefore = gcMillis() + val elapsed = + measureNanoTime { + findings = SimilarPasswordChecker().check(candidates).findings.size + } + val allocated = allocatedBytes() - allocatedBefore + val gc = gcMillis() - gcBefore + val millis = elapsed / 1_000_000.0 + line( + "%10d %14d %14.1f %12.0f %12d %12d %10d".format( + size, pairs, millis, pairs / max(millis, 0.001), findings, + allocated / (1024 * 1024), gc, + ), + ) + } catch (error: OutOfMemoryError) { + // The findings set holds one entry per flagged pair, so it grows with the + // square of the vault too. Running out of room is a result, not a mishap. + line("%10d %14d %14s".format(size, pairs, "OOM: ${error.message}")) + } + } + + private fun edgesOf(findings: List): Set { + val edges = HashSet() + findings.filterIsInstance().forEach { finding -> + val ids = finding.relatedItemIds.toList() + for (i in ids.indices) for (j in i + 1..ids.lastIndex) + edges += PairKey.of(ids[i], ids[j]) + } + return edges + } + + // region accumulation + + private inner class Stats { + var truePositives = 0L + var falsePositives = 0L + var falseNegatives = 0L + var duplicatesFlagged = 0L + var truePairs = 0L + var duplicatePairs = 0L + var scoredPairs = 0L + var items = 0L + var comparable = 0L + var findings = 0L + var multiMemberFindings = 0L + + var groups = 0L + var contaminatedGroups = 0L + var itemsInGroups = 0L + var largestGroup = 0 + var largestGroupSecrets = 0 + + val vaultSizes = mutableListOf() + val falsePositivesPerVault = mutableListOf() + val contaminatedPerVault = mutableListOf() + + val recallByVariant = HashMap() + val recallBySteps = HashMap() + val recallByShape = HashMap() + val causes = HashMap() + val verdicts = HashMap() + val examples = HashMap>() + val missedExamples = mutableListOf() + + fun accumulate(corpus: Corpus, predicted: Set, raw: List) { + val size = corpus.entries.size + vaultSizes += size + items += size + comparable += corpus.entries.count { it.password.length >= 6 } + findings += raw.count { it is HealthFinding.Relation } + multiMemberFindings += raw.filterIsInstance() + .count { it.relatedItemIds.size > 2 } + + truePairs += corpus.variantPairs.size + duplicatePairs += corpus.duplicatePairs.size + scoredPairs += size.toLong() * (size - 1) / 2 - corpus.duplicatePairs.size + + var vaultFalsePositives = 0 + predicted.forEach { pair -> + when { + pair in corpus.variantPairs -> truePositives++ + pair in corpus.duplicatePairs -> duplicatesFlagged++ + else -> { + falsePositives++ + vaultFalsePositives++ + record(corpus, pair) + } + } + } + falsePositivesPerVault += vaultFalsePositives + + corpus.variantPairs.forEach { pair -> + val first = corpus.byId.getValue(pair.a) + val second = corpus.byId.getValue(pair.b) + val hit = pair in predicted + if (!hit) { + falseNegatives++ + if (missedExamples.size < 25) missedExamples += + "%-28s %-28s (%s)".format( + first.password, second.password, + if (first.relation == "base") second.relation else first.relation, + ) + } + val kind = when { + first.relation == "base" -> second.relation + second.relation == "base" -> first.relation + else -> "variant-of-variant" + } + recallByVariant.tally(kind, hit) + recallBySteps.tally(abs(first.distanceFromBase - second.distanceFromBase), hit) + recallByShape.tally(first.archetype, hit) + } + + contaminatedPerVault += contamination(corpus, predicted) + } + + private fun record(corpus: Corpus, pair: PairKey) { + val first = corpus.byId.getValue(pair.a) + val second = corpus.byId.getValue(pair.b) + val cause = cause(first, second) + val verdict = verdict(first.password, second.password) + causes[cause] = (causes[cause] ?: 0) + 1 + verdicts[verdict] = (verdicts[verdict] ?: 0) + 1 + val bucket = examples.getOrPut(verdict) { mutableListOf() } + if (bucket.size < 8) bucket += "[%s] %-28s %-28s".format( + cause, first.password, second.password, + ) + } + + private fun contamination(corpus: Corpus, predicted: Set): Int { + val parent = HashMap() + fun find(x: ItemId): ItemId { + var root = parent.getOrPut(x) { x } + while (root != parent.getValue(root)) root = parent.getValue(root) + var cursor = x + while (cursor != root) { + val next = parent.getValue(cursor) + parent[cursor] = root + cursor = next + } + return root + } + predicted.forEach { + val a = find(it.a) + val b = find(it.b) + if (a != b) parent[a] = b + } + + val grouped = parent.keys.toList().groupBy(::find) + var contaminated = 0 + grouped.forEach { (_, members) -> + val secrets = members.map { corpus.byId.getValue(it).family }.toSet() + if (secrets.size > 1) contaminated++ + if (members.size > largestGroup) { + largestGroup = members.size + largestGroupSecrets = secrets.size + } + } + groups += grouped.size + contaminatedGroups += contaminated + itemsInGroups += parent.size + return contaminated + } + + fun write() { + val negatives = scoredPairs - truePairs + val trueNegatives = negatives - falsePositives + val precision = ratio(truePositives, truePositives + falsePositives) + val recall = ratio(truePositives, truePairs) + val f1 = + if (precision + recall == 0.0) 0.0 else 2 * precision * recall / (precision + recall) + + line("candidates $items (comparable, >=6 chars: $comparable)") + line("true variant pairs $truePairs") + line("exact dup pairs $duplicatePairs (out of scope, excluded from scoring)") + line("scored pairs $scoredPairs (negatives: $negatives)") + line("relation findings $findings ($multiMemberFindings with more than 2 members)") + line("") + line("TP $truePositives") + line("FP $falsePositives") + line("FN $falseNegatives") + line("TN $trueNegatives") + // A copy riding along inside a wider leet family, not a stray reuse edge: the + // checker never reports a pair of copies on its own. Counted here because + // expanding a family into pairs makes them look like one. + line("dup pairs inside families $duplicatesFlagged") + line("") + line("precision ${pct(precision)}") + line("recall ${pct(recall)}") + line("F1 ${"%.4f".format(f1)}") + line("specificity ${pct(ratio(trueNegatives, negatives))}") + val perMillion = falsePositives * 1_000_000.0 / max(negatives, 1) + line("FP per 1M neg pairs ${"%.1f".format(perMillion)}") + line("FP per 100 passwords ${"%.2f".format(falsePositives * 100.0 / max(items, 1))}") + line("") + line("groups shown $groups ($itemsInGroups items)") + val contaminatedShare = pct(ratio(contaminatedGroups, groups)) + line("contaminated groups $contaminatedGroups $contaminatedShare") + line( + "largest group $largestGroup items covering " + + "$largestGroupSecrets unrelated secrets" + ) + line("") + + recallByVariant.writeRecall("recall by how the variation was made") + recallBySteps.writeRecall("recall by edits between the two") { "$it step(s)" } + recallByShape.writeRecall("recall by password shape") + line("-- missed examples --") + missedExamples.take(15).forEach { line(" $it") } + line("") + + line("-- does the false flag make sense? --") + verdicts.entries.sortedByDescending { it.value }.forEach { (key, count) -> + line("%-42s %8d %s".format(key, count, pct(ratio(count, falsePositives)))) + } + line("-- what the two passwords shared --") + causes.entries.sortedByDescending { it.value }.take(16).forEach { (key, count) -> + line("%-42s %8d %s".format(key, count, pct(ratio(count, falsePositives)))) + } + line("-- false positive examples --") + examples.entries.sortedBy { it.key }.forEach { (verdict, rows) -> + line(" $verdict") + rows.forEach { line(" $it") } + } + line("") + } + } + + private fun HashMap.tally(key: K, hit: Boolean) { + val bucket = getOrPut(key) { LongArray(2) } + bucket[1]++ + if (hit) bucket[0]++ + } + + /** One `hits/total` table, ordered by its key. */ + private fun > Map.writeRecall( + title: String, + name: (K) -> String = { it.toString() }, + ) { + line("-- $title --") + entries.sortedBy { it.key }.forEach { (key, bucket) -> + line( + "%-22s %7d/%-7d %s".format( + name(key), bucket[0], bucket[1], pct(ratio(bucket[0], bucket[1])), + ), + ) + } + } + + // endregion + + // region judging a false positive + + /** The surface structure that made the checker join two unrelated passwords. */ + private fun cause(first: CorpusEntry, second: CorpusEntry): String = when { + first.cluster == second.cluster && first.cluster != "none" -> first.cluster + else -> listOf(first.archetype, second.archetype).sorted().joinToString(" + ") + } + + /** + * Whether a flag on an unrelated pair is still worth showing. It is when knowing one + * password narrows the search for the other: near-identical text, or a shared base a + * rule-based cracker would try anyway. It is not when both passwords keep material the + * other tells you nothing about, and never when all they share is a site name, which is + * public. + */ + private fun verdict(first: String, second: String): String { + val canonicalFirst = first.toCharArray().canonicalize() + val canonicalSecond = second.toCharArray().canonicalize() + val shorter = min(first.length, second.length) + val rows = DistanceRows(max(canonicalFirst.size, canonicalSecond.size)) + if (boundedDistance(canonicalFirst, canonicalSecond, shorter, rows) <= 2) + return "defensible: near-identical text" + + val prefix = commonPrefixLength(canonicalFirst, canonicalSecond) + val suffix = min(commonSuffixLength(canonicalFirst, canonicalSecond), shorter - prefix) + val shared = first.take(prefix) + first.takeLast(max(suffix, 0)) + if (SimilarityCorpus.siteNames.any { shared.contains(it, ignoreCase = true) }) + return "wrong: all they share is a site name" + + val restFirst = first.drop(prefix).dropLast(max(suffix, 0)) + val restSecond = second.drop(prefix).dropLast(max(suffix, 0)) + if (isSecret(restFirst) && isSecret(restSecond)) + return "wrong: each keeps material the other does not reveal" + + return "defensible: shared guessable base" + } + + private val YEAR = Regex("(19|20)\\d{2}") + + /** Whether a fragment is unguessable: long enough, not a word, not just a year. */ + private fun isSecret(fragment: String): Boolean { + val stripped = YEAR.replace(fragment, "").filter { it.isLetterOrDigit() } + if (stripped.length < 4) return false + if (stripped.lowercase() in SimilarityCorpus.vocabulary) return false + return !stripped.all { it.isDigit() } + } + + // endregion + + private fun cores() = Runtime.getRuntime().availableProcessors() + + /** + * Bytes handed out across every live thread. boundedDistance allocates three int rows + * per comparison that survives its length prefilter, so the sweep churns far more + * memory than it holds, and that churn does not show up in a heap reading. + */ + private fun allocatedBytes(): Long { + val threads = ManagementFactory.getThreadMXBean() as? com.sun.management.ThreadMXBean + ?: return 0 + return threads.getThreadAllocatedBytes(threads.allThreadIds).filter { it > 0 }.sum() + } + + private fun gcMillis() = + ManagementFactory.getGarbageCollectorMXBeans().sumOf { it.collectionTime } + + private fun env(name: String) = System.getenv(name)?.takeIf { it.isNotBlank() } + + private fun sizes(name: String, default: List): List = + env(name)?.split(',')?.mapNotNull { it.trim().toIntOrNull() }?.takeIf { it.isNotEmpty() } + ?: default + + private fun line(text: String) { + report.append(text).append('\n') + } + + private fun ratio(numerator: Number, denominator: Number): Double { + val d = denominator.toDouble() + return if (d == 0.0) 0.0 else numerator.toDouble() / d + } + + private fun pct(value: Double) = "%.2f%%".format(value * 100) +} diff --git a/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/domain/checker/SimilarPasswordCheckerTest.kt b/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/domain/checker/SimilarPasswordCheckerTest.kt new file mode 100644 index 000000000..fde960bcc --- /dev/null +++ b/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/domain/checker/SimilarPasswordCheckerTest.kt @@ -0,0 +1,254 @@ +package de.davis.keygo.feature.password_health.domain.checker + +import de.davis.keygo.feature.password_health.domain.model.HealthFinding +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals + +class SimilarPasswordCheckerTest { + + // region what the checker is for + + @Test + fun flagsALeetspeakRewrite() = runTest { + assertGrouped(setOf("password!", "P@ssw0rd!"), flag("password!", "P@ssw0rd!")) + } + + @Test + fun flagsABumpedYear() = runTest { + assertGrouped(setOf("Summer2023!", "Summer2024!"), flag("Summer2023!", "Summer2024!")) + } + + @Test + fun flagsASiteSwappedAroundASharedToken() = runTest { + assertGrouped(setOf("facebook_Kd8!", "twitter_Kd8!"), flag("facebook_Kd8!", "twitter_Kd8!")) + } + + @Test + fun flagsAWrappedTokenWithTheSiteSwapped() = runTest { + assertGrouped( + setOf("Kq7!facebook!Kq7", "Kq7!twitter!Kq7"), + flag("Kq7!facebook!Kq7", "Kq7!twitter!Kq7"), + ) + } + + @Test + fun flagsAnIncrementedCounter() = runTest { + assertGrouped( + setOf("Thistle-Garden-41", "Thistle-Garden-42"), + flag("Thistle-Garden-41", "Thistle-Garden-42") + ) + } + + // endregion + + // region a number that moved, and how far + + /** + * A counter someone bumped moves by one; eighty-two apart is two people picking a + * number. Without this every pair sharing a word and a symbol is reported, which was + * the single largest group of false pairs. + */ + @Test + fun ignoresTwoNumbersNowhereNearEachOther() = runTest { + assertEquals(emptySet(), flag("Phoenix*10", "Phoenix*92")) + } + + @Test + fun ignoresAYearThatJumpedHalfADecade() = runTest { + assertEquals(emptySet(), flag("Ginger2021-", "Ginger2015-")) + } + + /** A year rolling forward a couple of times is still one person editing one password. */ + @Test + fun flagsAYearThatMovedOnAStep() = runTest { + assertGrouped(setOf("Ginger2021-", "Ginger2023-"), flag("Ginger2021-", "Ginger2023-")) + } + + /** + * Two shared words say the base is the same. They do not say the rest is one edit: + * here the punctuation changed and the counter jumped nineteen, which is two decisions + * and therefore two passwords. + */ + @Test + fun ignoresATwoWordBaseThatWasRepunctuatedAndRenumbered() = runTest { + assertEquals(emptySet(), flag("jasper-rocket-53", "jasper.rocket.34")) + } + + /** The year and the symbol both moved, which is two decisions rather than one edit. */ + @Test + fun ignoresATwoWordBaseWhoseYearAndSymbolBothMoved() = runTest { + assertEquals(emptySet(), flag("P3n9uinLaurel2024@", "P3n9uinLaurel2003+")) + } + + /** + * Counting on the keyboard is not a secret anyone has to crack, so two weak passwords + * that agree on nothing else are two weak passwords, not a pair. + */ + @Test + fun ignoresTwoWeakPasswordsSharingOnlyACountingRun() = runTest { + assertEquals(emptySet(), flag("12345678", "1234qwer\$")) + } + + /** A number that is not going anywhere in particular is still material. */ + @Test + fun flagsASharedRunOfUnremarkableDigits() = runTest { + assertGrouped( + setOf("48213_facebook", "48213_twitter"), + flag("48213_facebook", "48213_twitter") + ) + } + + // endregion + + // region one password is the whole of another + + /** + * The commonest way a base gets reused, and the edit budget is the wrong tool for it: + * the budget is proportional to length and a site token is not. + */ + @Test + fun flagsATokenAppendedToAPassphrase() = runTest { + assertGrouped( + setOf("purple-tiger-canyon-rowan", "purple-tiger-canyon-rowanBcix9"), + flag("purple-tiger-canyon-rowan", "purple-tiger-canyon-rowanBcix9"), + ) + } + + /** + * Containment is evidence even when the shared part is guessable throughout. Nothing + * about "Summer2023" has to be cracked, but an attacker who has the longer password + * types the shorter one in next. + */ + @Test + fun flagsASiteTokenAppendedToAGuessableBase() = runTest { + assertGrouped( + setOf("Summer2023", "Summer2023!ForWork"), + flag("Summer2023", "Summer2023!ForWork"), + ) + } + + @Test + fun flagsABasePrependedTo() = runTest { + assertGrouped( + setOf("Kq7!Vn2Lp", "work-Kq7!Vn2Lp"), + flag("Kq7!Vn2Lp", "work-Kq7!Vn2Lp"), + ) + } + + /** + * A word that happens to begin a longer word is not a password inside a password, or + * every passphrase built on a stem drags its neighbours along. + */ + @Test + fun ignoresAWordThatMerelyBeginsALongerWord() = runTest { + assertEquals(emptySet(), flag("carpenter", "carpenters")) + } + + // endregion + + // region what belongs to another check, or to nothing + + @Test + fun leavesExactCopiesToTheReuseCheck() = runTest { + assertEquals(emptySet(), flag("Hunter2024!", "Hunter2024!")) + } + + /** + * Each copy really is a variation of the rewrite, whatever the copies are to each + * other. What the class rules out is a relation that is nothing but a reused pair, + * which [leavesExactCopiesToTheReuseCheck] pins. + */ + @Test + fun keepsExactCopiesInsideAWiderFamily() = runTest { + val candidates = candidates("Password1", "Password1", "Password!") + + val relations = SimilarPasswordChecker().check(candidates) + .findings + .filterIsInstance() + + assertEquals(1, relations.size) + assertEquals(candidates.mapTo(mutableSetOf()) { it.id }, relations.single().relatedItemIds) + } + + @Test + fun ignoresPasswordsTooShortToCompare() = runTest { + assertEquals(emptySet(), flag("ab1", "ab2")) + } + + @Test + fun ignoresAnUnrelatedPair() = runTest { + assertEquals(emptySet(), flag("Thistle2019#", "b7Kq-vn2LpXw")) + } + + // endregion + + // region a shared part an attacker gets for free is not evidence + + @Test + fun ignoresTwoAccountsOnTheSameSite() = runTest { + assertEquals(emptySet(), flag("sparkasse-Cf9ZhV", "sparkasse-N4KEz")) + } + + @Test + fun ignoresAWordSharedWithAMuchShorterPassword() = runTest { + assertEquals(emptySet(), flag("Harbor&94", "Harbor2013!")) + } + + @Test + fun ignoresAWordSharedWithAPassphrase() = runTest { + assertEquals(emptySet(), flag("Golden!71", "golden.penguin.bobcat.heather")) + } + + @Test + fun ignoresAHabitualSuffixOverUnrelatedWords() = runTest { + assertEquals(emptySet(), flag("Baseballviolet%41", "Parsleyviolet%41")) + } + + // endregion + + // region a word is free however it is spelled + // + // The plain spelling of each of these is already quiet. Respelling the word must not + // turn it back into evidence: leetspeak, inner capitals and shouting are what a + // rule-based cracker undoes first, so they cost an attacker nothing. + + @Test + fun ignoresAWordSharedInLeetSpelling() = runTest { + assertEquals(emptySet(), flag("Dr@gon2019!!", "Dr@gon94##")) + } + + @Test + fun ignoresAWordSharedInMixedCase() = runTest { + assertEquals(emptySet(), flag("PenGuIn2019_", "PenGuInCanyon1993-")) + } + + @Test + fun ignoresAWordSharedInCapitals() = runTest { + assertEquals(emptySet(), flag("HARBOR&94", "HARBOR2013!")) + } + + // endregion + + // region the difference has to be worth something too + + @Test + fun ignoresTwoAccountsOnTheSameSiteBehindShortTokens() = runTest { + assertEquals(emptySet(), flag("facebook_Kd8!", "facebook_Xq2!")) + } + + @Test + fun ignoresTwoDifferentWordsBehindTheSameCounter() = runTest { + assertEquals(emptySet(), flag("garden22", "harden22")) + } + + @Test + fun ignoresAWordWhoseYearAndSymbolBothMoved() = runTest { + assertEquals(emptySet(), flag("Vanilla2000+", "Vanilla2007#")) + } + + // endregion + + private fun assertGrouped(expected: Set, actual: Set>) = + assertEquals(setOf(expected), actual) +} diff --git a/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/domain/checker/SimilarityCorpus.kt b/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/domain/checker/SimilarityCorpus.kt new file mode 100644 index 000000000..057310b5a --- /dev/null +++ b/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/domain/checker/SimilarityCorpus.kt @@ -0,0 +1,635 @@ +package de.davis.keygo.feature.password_health.domain.checker + +import de.davis.keygo.core.item.domain.alias.ItemId +import de.davis.keygo.core.item.domain.model.PasswordScore +import de.davis.keygo.feature.password_health.domain.model.PasswordCandidate +import java.util.UUID +import kotlin.random.Random + +/** + * Labelled password corpora for measuring [SimilarPasswordChecker]. + * + * The label that matters is [CorpusEntry.family]: two entries share a family when one + * was built from the other by a transformation that keeps the unpredictable part of + * the password, so cracking one hands you the other. Everything else is a negative, + * including entries that share only public or predictable structure -- a year, a site + * name, a dictionary word, a habitual symbol suffix. + * + * Two shapes are generated. [vaults] models what the checker actually runs against: a + * person's vault, where a small personal vocabulary makes unrelated passwords look + * alike. [singleVault] is one big pile with a wide vocabulary, used for timing. + */ +internal data class CorpusEntry( + val id: ItemId, + val password: String, + /** Shared secret material. Negative values are unique to the entry. */ + val family: Int, + val archetype: String, + /** How this entry was derived: "base", a variant name, "duplicate" or "independent". */ + val relation: String, + /** Shared surface structure, for attributing false positives. */ + val cluster: String, + /** Transformation steps away from the family base. */ + val distanceFromBase: Int, +) + +/** Unordered pair of item ids. */ +internal data class PairKey(val a: ItemId, val b: ItemId) { + companion object { + fun of(first: ItemId, second: ItemId): PairKey = + if (first < second) PairKey(first, second) else PairKey(second, first) + } +} + +internal class Corpus(val entries: List) { + + val byId: Map = entries.associateBy { it.id } + + /** Pairs the checker is expected to flag: same secret material, different text. */ + val variantPairs: Set + + /** Same secret material and identical text. Out of scope, ReusePasswordCheck owns these. */ + val duplicatePairs: Set + + init { + val groups = UnionFind(entries.size) + + entries.indices.filter { entries[it].family >= 0 } + .groupBy { entries[it].family } + .values + .forEach { members -> members.forEach { groups.union(members.first(), it) } } + + joinNearIdenticalText(groups) + + val variants = mutableSetOf() + val duplicates = mutableSetOf() + entries.indices.groupBy { groups.find(it) }.values.forEach { component -> + for (i in component.indices) for (j in i + 1..component.lastIndex) { + val first = entries[component[i]] + val second = entries[component[j]] + val key = PairKey.of(first.id, second.id) + if (first.password == second.password) duplicates += key else variants += key + } + } + variantPairs = variants + duplicatePairs = duplicates + } + + /** + * Joins entries the generator drew separately but that a cracker would hand over + * together anyway. + * + * Whether two passwords are related is a property of the passwords, not of how they + * came to exist. The generator works from a small vocabulary on purpose, and small + * vocabularies collide: sixteen keyboard walks and eleven symbols cannot make many + * distinct passwords. Labelling those unrelated marks a checker wrong for finding a + * pair that really does fall together, and at one point that single artifact was 39% + * of every false pair measured. + * + * One edit is the whole allowance, deliberately. Two leaves room for a coincidence a + * person could have arrived at twice ("Phoenix*10" beside "Phoenix*92"), and those + * stay negatives, which is what keeps this from quietly agreeing with whatever the + * checker happens to do. + */ + private fun joinNearIdenticalText(groups: UnionFind) { + val canonical = entries.map { it.password.toCharArray().canonicalize() } + val rows = DistanceRows(canonical.maxOfOrNull { it.size } ?: return) + val byLength = canonical.indices.groupBy { canonical[it].size } + + canonical.indices.forEach { index -> + for (length in canonical[index].size..canonical[index].size + NEAR_EDITS) + byLength[length].orEmpty().forEach { other -> + if (other > index && groups.find(other) != groups.find(index) && + boundedDistance( + canonical[index], + canonical[other], + NEAR_EDITS, + rows, + ) <= NEAR_EDITS + ) groups.union(index, other) + } + } + } + + private class UnionFind(size: Int) { + private val parent = IntArray(size) { it } + + fun find(node: Int): Int { + var root = node + while (parent[root] != root) root = parent[root] + var walk = node + while (parent[walk] != root) { + val next = parent[walk] + parent[walk] = root + walk = next + } + return root + } + + fun union(first: Int, second: Int) { + parent[find(first)] = find(second) + } + } + + private companion object { + /** Edits at which one password plainly gives the other away, however it was written. */ + const val NEAR_EDITS = 1 + } + + fun candidates(): List = entries.map { + PasswordCandidate( + id = it.id, + score = PasswordScore.Moderate, + password = it.password.toCharArray(), + ) + } +} + +internal object SimilarityCorpus { + + /** Site names the corpus uses, so a report can tell a public token from a secret one. */ + val siteNames: List get() = SITES + + /** Every word the corpus builds passwords from, for the same reason. */ + val vocabulary: Set by lazy { + (WORDS + PSEUDO_WORDS + COMMON_WEAK + KEYBOARD_WALKS + SITES).toSet() + } + + /** A wordlist to draw passphrases from, of the order a real one has. */ + val passphraseWords: List get() = WORDS + PSEUDO_WORDS + + /** + * A vault of [size] entries built the way one person builds passwords: a handful of + * favourite words, the accounts they actually hold, and their own habits about how + * much they reuse. + */ + fun vault(size: Int, random: Random, vocabularyScale: Double = 1.0): Corpus { + val vocabulary = personalVocabulary(random, vocabularyScale) + val accounts = SITES.shuffled(random).take(random.nextInt(8, 24)) + return CorpusBuilder( + random = random, + words = vocabulary, + sites = accounts, + variantShare = random.nextDouble(0.05, 0.45), + duplicateShare = random.nextDouble(0.0, 0.15), + hardNegativeShare = 0.10, + ).build(size) + } + + /** + * [vocabularyScale] widens the personal vocabulary: 1.0 is a person with a dozen + * favourite words, 4.0 someone who rarely repeats one. It is the single assumption + * the false positive rate is most sensitive to, so it is a knob rather than a + * constant. + */ + fun vaults( + count: Int, + seed: Long = 20260919L, + sizes: IntRange = 20..400, + vocabularyScale: Double = 1.0, + ): List { + val random = Random(seed) + return List(count) { + // Small vaults are the common case, so skew towards the low end. + val size = minOf( + sizes.last, + sizes.first + (random.nextDouble() * random.nextDouble() * + (sizes.last - sizes.first)).toInt(), + ) + vault(size, random, vocabularyScale) + } + } + + /** One undifferentiated pile, for timing the pairwise sweep. */ + fun singleVault(size: Int, seed: Long = 20260919L): Corpus { + val random = Random(seed) + return CorpusBuilder( + random = random, + words = WORDS + PSEUDO_WORDS, + sites = SITES, + variantShare = 0.24, + duplicateShare = 0.05, + hardNegativeShare = 0.14, + ).build(size) + } + + private fun personalVocabulary(random: Random, scale: Double): List { + fun widen(low: Int, high: Int) = random.nextInt( + (low * scale).toInt().coerceAtLeast(1), + (high * scale).toInt().coerceAtLeast(2), + ) + + val favourites = WORDS.shuffled(random).take(widen(6, 13)) + val rest = PSEUDO_WORDS.shuffled(random).take(widen(4, 10)) + // Favourites are picked more often than the rest, the way a habit works. + return favourites + favourites + rest + } + + // region vocabulary + + private val WORDS = listOf( + "summer", "winter", "spring", "autumn", "dragon", "monkey", "sunshine", "princess", + "football", "baseball", "mustang", "shadow", "master", "jordan", "harley", "ranger", + "hunter", "buster", "soccer", "hockey", "thunder", "falcon", "phoenix", "marmot", + "thistle", "garden", "canyon", "lantern", "puzzle", "otter", "badger", "walnut", + "copper", "silver", "golden", "purple", "orange", "violet", "indigo", "crimson", + "amber", "jasper", "quartz", "granite", "marble", "cobalt", "nickel", "carbon", + "oxygen", "helium", "argon", "kepler", "orion", "sirius", "nebula", "comet", + "meteor", "galaxy", "cosmos", "saturn", "jupiter", "mercury", "neptune", "titan", + "europa", "rocket", "anchor", "harbor", "compass", "seagull", "dolphin", "narwhal", + "penguin", "walrus", "tiger", "leopard", "cheetah", "panther", "jaguar", "cougar", + "bobcat", "coyote", "raven", "sparrow", "heron", "egret", "pelican", "osprey", + "kestrel", "condor", "vulture", "crane", "stork", "maple", "cedar", "birch", + "aspen", "willow", "poplar", "spruce", "juniper", "laurel", "myrtle", "hazel", + "alder", "rowan", "hawthorn", "bramble", "clover", "meadow", "prairie", "tundra", + "glacier", "summit", "ridge", "hollow", "boulder", "pebble", "cinder", "ember", + "kindle", "beacon", "mantle", "keystone", "archway", "cellar", "attic", + "bureau", "satchel", "kettle", "skillet", "saucer", "goblet", "tankard", "flagon", + "banner", "pennant", "cobble", "gravel", "thicket", "bracken", "heather", "gorse", + "nettle", "sorrel", "fennel", "parsley", "saffron", "cumin", "pepper", "ginger", + "cinnamon", "vanilla", "almond", "cashew", "pecan", "chestnut", "acorn", "cypress", + ) + + /** + * Pronounceable filler so a large corpus does not force unrelated passwords to share + * a word just because the word list ran out. They behave like real words everywhere + * the checker looks: all lowercase letters, spelled the way [WordModel] expects. + */ + private val PSEUDO_WORDS: List = buildSet { + val random = Random(7L) + val onsets = listOf( + "b", "c", "d", "f", "g", "h", "j", "k", "l", "m", "n", "p", "r", "s", "t", "v", + "w", "z", "br", "cr", "dr", "fl", "gl", "pl", "sh", "st", "tr", "cl", "sp", "sn", + ) + val nuclei = listOf("a", "e", "i", "o", "u", "ai", "ea", "ee", "oo", "ou", "au", "ie") + val codas = listOf("", "n", "r", "l", "s", "t", "m", "k", "d", "ng", "rd", "ll", "nt") + while (size < 4_000) { + val word = (1..random.nextInt(2, 4)).joinToString("") { + onsets.random(random) + nuclei.random(random) + codas.random(random) + } + if (word.length in 4..11) add(word) + } + }.toList() + + private val SITES = listOf( + "facebook", "twitter", "google", "amazon", "netflix", "spotify", "github", "gitlab", + "reddit", "linkedin", "instagram", "tiktok", "dropbox", "slack", "discord", "steam", + "paypal", "stripe", "chase", "barclays", "revolut", "sparkasse", "comdirect", "ebay", + "etsy", "shopify", "zalando", "mediamarkt", "lidl", "rewe", "booking", "airbnb", + "expedia", "ryanair", "lufthansa", "bahn", "uber", "doordash", "deliveroo", "audible", + "kindle", "hulu", "disney", "twitch", "youtube", "vimeo", "notion", "figma", + "atlassian", "cloudflare", "namecheap", "hetzner", "digitalocean", "protonmail", + ) + + private val COMMON_WEAK = listOf( + "password", "password1", "password123", "123456", "1234567", "12345678", "123456789", + "qwerty", "qwerty123", "letmein", "iloveyou", "welcome", "monkey123", "abc123", + "trustno1", "dragon123", "sunshine1", "princess1", "football1", "starwars", + "whatever", "changeme", "admin123", "passw0rd", "qazwsx", "zaq12wsx", "michael1", + "superman1", "batman123", "pokemon1", "minecraft", "liverpool", "arsenal1", + "chelsea1", "barcelona", "juventus", "ferrari1", "porsche911", "corvette", + ) + + private val KEYBOARD_WALKS = listOf( + "qwertyuiop", "asdfghjkl", "zxcvbnm123", "1qaz2wsx", "2wsx3edc", "3edc4rfv", + "qazwsxedc", "1q2w3e4r", "2w3e4r5t", "qwerasdf", "asdfzxcv", "poiuytrewq", + "mnbvcxz123", "1234qwer", "4rfv5tgb", "zaqxswcde", + ) + + private val SYMBOLS = listOf('!', '@', '#', '%', '&', '*', '?', '-', '_', '+', '$') + private val SEPARATORS = listOf('-', '_', '.', ' ') + private const val TOKEN_ALPHABET = "abcdefghijkmnpqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789" + private const val STRONG_ALPHABET = TOKEN_ALPHABET + "!@#%&*?-_+$" + + private val LEET_OUT = mapOf( + 'a' to '@', 'o' to '0', 'e' to '3', 's' to '$', + 'i' to '1', 't' to '7', 'b' to '8', 'g' to '9', + ) + + private val YEAR_PATTERN = Regex("(19|20)\\d{2}") + + private enum class Archetype(val label: String, val weight: Int) { + WordYearSymbol("word+year+symbol", 12), + WordSymbolDigits("word+symbol+digits", 10), + TwoWordSeparated("two-word", 12), + SitePrefixToken("site-prefix+token", 10), + TokenSiteToken("token+site+token", 5), + CommonWeak("common-weak", 5), + RandomStrong("random-strong", 20), + Passphrase("passphrase", 12), + LeetWordYear("leet word+year", 9), + KeyboardWalk("keyboard-walk", 5), + } + + private val ARCHETYPE_POOL = Archetype.entries.flatMap { a -> List(a.weight) { a } } + + /** + * Every transformation here keeps the part of the password an attacker cannot guess, + * so the result belongs in the same family as its input. + */ + private enum class VariantKind(val label: String) { + Leet("leet"), + CaseFlip("case-flip"), + YearBump("year-bump"), + CounterBump("counter-bump"), + SymbolSwap("symbol-swap"), + SiteSwap("site-swap"), + Typo("typo"), + AppendToken("append-token"), + } + + private enum class HardNegative(val label: String) { + /** Different words, same year and symbol. The year is all they share. */ + SharedYear("shared-year"), + + /** Two accounts on one site, unrelated secrets. */ + SameSiteDifferentSecret("same-site-different-secret"), + + /** Passphrases overlapping in one word out of four. */ + SharedPassphraseWord("shared-passphrase-word"), + + /** Unrelated bases behind the same habitual suffix. */ + SharedSuffix("shared-suffix"), + + /** The same favourite word inside different templates. */ + SharedWord("shared-word"), + + /** Different walks across the same keyboard. */ + KeyboardNeighbours("keyboard-neighbours"), + } + + // endregion + + private class CorpusBuilder( + private val random: Random, + private val words: List, + private val sites: List, + private val variantShare: Double, + private val duplicateShare: Double, + private val hardNegativeShare: Double, + ) { + + private val seen = HashSet() + private val entries = ArrayList() + private var family = 0 + + fun build(size: Int): Corpus { + var produced = 0 + while (produced < size * variantShare) produced += emitFamily() + + produced = 0 + while (produced < size * duplicateShare && entries.size < size) { + val (base, archetype) = fresh() + val id = family++ + emit(base, id, archetype, "base", "family-$id") + emit(base, id, archetype, "duplicate", "family-$id") + produced += 2 + } + + produced = 0 + while (produced < size * hardNegativeShare && entries.size < size) { + val cluster = HardNegative.entries.random(random) + hardNegative(cluster).forEach { (archetype, password) -> + emit(password, unique(), archetype, "independent", cluster.label) + produced++ + } + } + + while (entries.size < size) { + val (password, archetype) = fresh() + emit(password, unique(), archetype, "independent", "none") + } + + return Corpus(entries.take(size).shuffled(random)) + } + + private fun emitFamily(): Int { + val (base, archetype) = fresh() + val id = family++ + emit(base, id, archetype, "base", "family-$id") + var produced = 1 + + // A family is either one base with several rewrites, or a password that + // evolved step by step over the years. + val chained = random.nextInt(10) < 3 + var current = base + var steps = 0 + repeat(random.nextInt(1, 4)) { + val from = if (chained) current else base + val variant = applyVariant(from) ?: return@repeat + steps = if (chained) steps + 1 else 1 + emit(variant.second, id, archetype, variant.first, "family-$id", steps) + current = variant.second + produced++ + } + return produced + } + + private fun unique() = -(entries.size + 1) + + private fun emit( + password: String, + family: Int, + archetype: String, + relation: String, + cluster: String, + distanceFromBase: Int = 0, + ) { + entries += CorpusEntry( + id = UUID(0L, entries.size.toLong()), + password = password, + family = family, + archetype = archetype, + relation = relation, + cluster = cluster, + distanceFromBase = distanceFromBase, + ) + } + + private fun fresh(): Pair { + repeat(64) { + val archetype = ARCHETYPE_POOL.random(random) + val password = build(archetype) + if (seen.add(password)) return password to archetype.label + } + val fallback = build(Archetype.RandomStrong) + seen.add(fallback) + return fallback to Archetype.RandomStrong.label + } + + private fun word() = words.random(random) + + private fun capitalised() = word().replaceFirstChar { it.uppercase() } + + private fun year() = random.nextInt(1990, 2027).toString() + + private fun token(length: Int, alphabet: String = TOKEN_ALPHABET) = + String(CharArray(length) { alphabet[random.nextInt(alphabet.length)] }) + + private fun leet(text: String, count: Int): String { + val positions = text.indices.filter { text[it].lowercaseChar() in LEET_OUT } + if (positions.isEmpty()) return text + val chars = text.toCharArray() + positions.shuffled(random).take(count).forEach { + chars[it] = LEET_OUT.getValue(chars[it].lowercaseChar()) + } + return String(chars) + } + + private fun build(archetype: Archetype): String = when (archetype) { + Archetype.WordYearSymbol -> capitalised() + year() + SYMBOLS.random(random) + + Archetype.WordSymbolDigits -> + capitalised() + SYMBOLS.random(random) + random.nextInt(10, 100) + + Archetype.TwoWordSeparated -> { + val separator = SEPARATORS.random(random) + word() + separator + word() + separator + random.nextInt(10, 100) + } + + Archetype.SitePrefixToken -> + sites.random(random) + SEPARATORS.random(random) + token(random.nextInt(4, 7)) + + Archetype.TokenSiteToken -> { + val token = token(3) + val symbol = SYMBOLS.random(random) + "$token$symbol${sites.random(random)}$symbol$token" + } + + Archetype.CommonWeak -> COMMON_WEAK.random(random) + + Archetype.RandomStrong -> token(random.nextInt(16, 23), STRONG_ALPHABET) + + Archetype.Passphrase -> { + val separator = SEPARATORS.random(random) + (1..4).joinToString(separator.toString()) { word() } + } + + Archetype.LeetWordYear -> leet( + capitalised() + capitalised() + year() + SYMBOLS.random(random), + count = random.nextInt(1, 3), + ) + + Archetype.KeyboardWalk -> KEYBOARD_WALKS.random(random) + SYMBOLS.random(random) + } + + private fun applyVariant(password: String): Pair? { + repeat(16) { + val kind = VariantKind.entries.random(random) + val candidate = variant(kind, password) + if (candidate != null && candidate != password && candidate.length >= 4 && + seen.add(candidate) + ) return kind.label to candidate + } + return null + } + + private fun variant(kind: VariantKind, password: String): String? = when (kind) { + VariantKind.Leet -> leet(password, count = random.nextInt(1, 3)) + + VariantKind.CaseFlip -> { + val letters = password.indices.filter { password[it].isLetter() } + if (letters.isEmpty()) null else { + val chars = password.toCharArray() + letters.shuffled(random).take(random.nextInt(1, 3)).forEach { + chars[it] = if (chars[it].isUpperCase()) chars[it].lowercaseChar() + else chars[it].uppercaseChar() + } + String(chars) + } + } + + VariantKind.YearBump -> YEAR_PATTERN.find(password)?.let { + val bumped = it.value.toInt() + random.nextInt(1, 4) + password.replaceRange(it.range, bumped.toString()) + } + + VariantKind.CounterBump -> { + val trailing = password.takeLastWhile { it.isDigit() } + if (trailing.isEmpty()) password + random.nextInt(1, 10) + else password.dropLast(trailing.length) + (trailing.toLong() + 1) + } + + VariantKind.SymbolSwap -> password.lastOrNull()?.takeIf { it in SYMBOLS }?.let { last -> + password.dropLast(1) + SYMBOLS.filter { it != last }.random(random) + } + + VariantKind.SiteSwap -> sites.firstOrNull { password.contains(it, ignoreCase = true) } + ?.let { present -> + val others = sites.filter { it != present } + if (others.isEmpty()) null else password.replace(present, others.random(random)) + } + + VariantKind.Typo -> { + val chars = password.toCharArray() + val at = random.nextInt(chars.size) + when (random.nextInt(4)) { + 0 -> password.replaceRange(at, at + 1, TOKEN_ALPHABET.random(random).toString()) + 1 -> password.removeRange(at, at + 1) + 2 -> StringBuilder(password).insert(at, TOKEN_ALPHABET.random(random)) + .toString() + + else -> if (at + 1 < chars.size) { + chars[at] = password[at + 1] + chars[at + 1] = password[at] + String(chars) + } else null + } + } + + VariantKind.AppendToken -> password + token(random.nextInt(1, 4)) + } + + private fun hardNegative(cluster: HardNegative): List> { + val members = when (cluster) { + HardNegative.SharedYear -> { + val year = year() + val symbol = SYMBOLS.random(random) + List(random.nextInt(2, 5)) { + "word+year+symbol" to capitalised() + capitalised() + year + symbol + } + } + + HardNegative.SameSiteDifferentSecret -> { + val site = sites.random(random) + val separator = SEPARATORS.random(random) + List(random.nextInt(2, 4)) { + "site-prefix+token" to site + separator + token(random.nextInt(4, 7)) + } + } + + HardNegative.SharedPassphraseWord -> { + val shared = word() + val separator = SEPARATORS.random(random) + List(2) { + "passphrase" to (listOf(shared) + List(3) { word() }) + .joinToString(separator.toString()) + } + } + + HardNegative.SharedSuffix -> { + val suffix = "${SYMBOLS.random(random)}${random.nextInt(10, 100)}" + List(random.nextInt(2, 4)) { + "word+symbol+digits" to capitalised() + word() + suffix + } + } + + HardNegative.SharedWord -> { + val shared = word() + listOf( + "word+year+symbol" to shared.replaceFirstChar { it.uppercase() } + + year() + SYMBOLS.random(random), + "two-word" to shared + SEPARATORS.random(random) + word() + + random.nextInt(10, 100), + "site-prefix+token" to sites.random(random) + "_" + shared + token(3), + ) + } + + HardNegative.KeyboardNeighbours -> KEYBOARD_WALKS.shuffled(random).take(2).map { + "keyboard-walk" to it + SYMBOLS.random(random) + } + } + return members.filter { seen.add(it.second) } + } + } +} diff --git a/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/domain/checker/SimilarityCorpusTest.kt b/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/domain/checker/SimilarityCorpusTest.kt new file mode 100644 index 000000000..1f3c596bd --- /dev/null +++ b/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/domain/checker/SimilarityCorpusTest.kt @@ -0,0 +1,82 @@ +package de.davis.keygo.feature.password_health.domain.checker + +import java.util.UUID +import kotlin.test.Test +import kotlin.test.assertEquals + +/** + * The corpus is the instrument every other measurement is read off, so what it calls a + * family has to be true of the passwords rather than true of how they were generated: a + * generator draws independently and still collides. + */ +class SimilarityCorpusTest { + + @Test + fun readsTwoIndependentEntriesOneEditApartAsOneFamily() { + val corpus = corpus("4rfv5tgb+" to -1, "4rfv5tgb&" to -2) + + assertEquals(setOf(pair(0, 1)), corpus.variantPairs) + } + + /** Two edits apart is a coincidence a person could plausibly have arrived at twice. */ + @Test + fun leavesTwoIndependentEntriesTwoEditsApartUnrelated() { + val corpus = corpus("Phoenix*10" to -1, "Phoenix*92" to -2) + + assertEquals(emptySet(), corpus.variantPairs) + } + + /** Spelling is not distance: one of these is the other, written differently. */ + @Test + fun readsALeetRewriteAsTheSameFamily() { + val corpus = corpus("Summer2019!" to -1, "5umm3r2019!" to -2) + + assertEquals(setOf(pair(0, 1)), corpus.variantPairs) + } + + /** Whoever is one edit from both of them relates the two. */ + @Test + fun joinsFamiliesThroughAnEntryThatSitsBetweenThem() { + val corpus = corpus( + "harbor-meadow-84" to 7, + "harbor-meadow-85" to -2, + "harbor-meadow-86" to -3, + ) + + assertEquals(setOf(pair(0, 1), pair(0, 2), pair(1, 2)), corpus.variantPairs) + } + + /** A declared family still holds together however far apart its members drifted. */ + @Test + fun keepsADeclaredFamilyThatNoDistanceWouldHaveFound() { + val corpus = corpus("Baseball2008?" to 3, "Baseball2012-" to 3) + + assertEquals(setOf(pair(0, 1)), corpus.variantPairs) + } + + @Test + fun countsIdenticalTextAsADuplicateRatherThanAVariation() { + val corpus = corpus("Summer2019!" to 3, "Summer2019!" to 3) + + assertEquals(emptySet(), corpus.variantPairs) + assertEquals(setOf(pair(0, 1)), corpus.duplicatePairs) + } + + private fun corpus(vararg entries: Pair) = Corpus( + entries.mapIndexed { index, (password, family) -> + CorpusEntry( + id = id(index), + password = password, + family = family, + archetype = "test", + relation = if (family >= 0) "base" else "independent", + cluster = "none", + distanceFromBase = 0, + ) + }, + ) + + private fun id(index: Int) = UUID(0L, index.toLong()) + + private fun pair(first: Int, second: Int) = PairKey.of(id(first), id(second)) +} diff --git a/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/domain/checker/SimilaritySensitivitySweep.kt b/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/domain/checker/SimilaritySensitivitySweep.kt new file mode 100644 index 000000000..0281b45e7 --- /dev/null +++ b/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/domain/checker/SimilaritySensitivitySweep.kt @@ -0,0 +1,266 @@ +package de.davis.keygo.feature.password_health.domain.checker + +import de.davis.keygo.feature.password_health.domain.model.HealthFinding +import kotlinx.coroutines.test.runTest +import java.io.File +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.time.Duration.Companion.hours + +/** + * Two follow-ups to [SimilarPasswordCheckerBenchmark]: which of the checker's three rules + * produces the false pairs, and whether moving its constants can reach an operating point + * worth shipping. + * + * SIMILARITY_VAULTS=2000 ./gradlew :feature:password-health:testDebugUnitTest \ + * --tests '*SimilaritySensitivitySweep*' + * + * The report lands in `build/reports/similarity/sweep.txt`. + */ +class SimilaritySensitivitySweep { + + private val report = StringBuilder() + + /** The replica is only worth reading if it still agrees with the shipped checker. */ + @Test + fun replicaMatchesShippedChecker() = runTest(timeout = 1.hours) { + SimilarityCorpus.vaults(60, seed = 4242L).forEach { corpus -> + val candidates = corpus.candidates() + val shipped = HashSet() + SimilarPasswordChecker().check(candidates) + .findings + .filterIsInstance() + .forEach { finding -> + val ids = finding.relatedItemIds.toList() + for (i in ids.indices) for (j in i + 1..ids.lastIndex) + shipped += PairKey.of(ids[i], ids[j]) + } + assertEquals(shipped, TunableSimilarity().edges(candidates).keys) + } + } + + @Test + fun sweep() = runTest(timeout = 4.hours) { + val count = System.getenv("SIMILARITY_VAULTS")?.toIntOrNull() ?: 500 + val corpora = SimilarityCorpus.vaults(count) + val passwords = corpora.sumOf { it.entries.size } + + line("SimilarPasswordChecker sensitivity sweep") + line("$count simulated vaults, $passwords passwords") + line("") + + attribution(corpora) + + line("== how much the result depends on the vocabulary assumption ==") + header() + listOf( + 1.0 to "a dozen favourite words", + 2.0 to "twice as many", + 4.0 to "rarely repeats one" + ) + .forEach { (scale, label) -> + score( + "personal vocabulary x$scale ($label)", + TunableSimilarity(), + SimilarityCorpus.vaults(count, vocabularyScale = scale), + ) + } + line("") + + line("== moving the constants ==") + header() + settings().forEach { (label, similarity) -> score(label, similarity, corpora) } + line("") + + line("== finer grid ==") + header() + grid().forEach { (label, similarity) -> score(label, similarity, corpora) } + line("") + + // Exactly what the edit cap costs: the same checker with and without it. + detail("no edit cap", TunableSimilarity(maxDistance = Int.MAX_VALUE), corpora) + detail("current, cap 4", TunableSimilarity(), corpora) + + val out = File( + System.getenv("SIMILARITY_REPORT_DIR") ?: "build/reports/similarity", + "sweep.txt", + ) + out.parentFile?.mkdirs() + out.writeText(report.toString()) + println(report) + println("report written to ${out.absolutePath}") + } + + /** Which rule is responsible for the right answers and which for the wrong ones. */ + private fun attribution(corpora: List) { + val truePositives = HashMap() + val falsePositives = HashMap() + val examples = HashMap>() + + corpora.forEach { corpus -> + TunableSimilarity().edges(corpus.candidates()).forEach { (pair, rule) -> + when { + pair in corpus.variantPairs -> + truePositives[rule] = (truePositives[rule] ?: 0) + 1 + + pair in corpus.duplicatePairs -> Unit + + else -> { + falsePositives[rule] = (falsePositives[rule] ?: 0) + 1 + val bucket = examples.getOrPut(rule) { mutableListOf() } + if (bucket.size < 6) bucket += "%-30s %-30s".format( + corpus.byId.getValue(pair.a).password, + corpus.byId.getValue(pair.b).password, + ) + } + } + } + } + + line("== which rule fired ==") + line("%-14s %12s %12s %12s".format("rule", "TP", "FP", "precision")) + TunableSimilarity.Rule.entries.forEach { rule -> + val tp = truePositives[rule] ?: 0 + val fp = falsePositives[rule] ?: 0 + line("%-14s %12d %12d %12s".format(rule.name, tp, fp, pct(ratio(tp, tp + fp)))) + } + line("") + TunableSimilarity.Rule.entries.forEach { rule -> + line("-- false pairs from ${rule.name} --") + examples[rule].orEmpty().forEach { line(" $it") } + } + line("") + } + + /** Where the two live thresholds sit on their curves, so each value is a decision. */ + private fun grid(): List> = + listOf(0.25, 0.3, 0.35, 0.4, 0.45, 0.5, 0.6, 0.7).map { share -> + "affixShare $share" to TunableSimilarity(affixShare = share) + } + listOf(3, 4, 5, 6, 8).map { cap -> + "edit budget capped at $cap" to TunableSimilarity(maxDistance = cap) + } + + /** Each part of the fix switched off on its own, to show what it is worth. */ + private fun settings(): List> = listOf( + "current" to TunableSimilarity(), + "edit rule asks for no evidence" to TunableSimilarity(requireEvidence = false), + "evidence needs 2 shared words" to TunableSimilarity(minSharedWords = 2), + "containment rule off" to TunableSimilarity(minContainedLength = 0), + "containment needs 12 characters" to TunableSimilarity(minContainedLength = 12), + "a number may move any distance" to TunableSimilarity(maxCounterStep = Long.MAX_VALUE), + "a number may move by 1" to TunableSimilarity(maxCounterStep = 1), + "a number may move by 10" to TunableSimilarity(maxCounterStep = 10), + "shared words buy half the budget" to TunableSimilarity(weakEvidenceDivisor = 2), + "shared words buy nothing" to TunableSimilarity(sharedWordsAsEvidence = Int.MAX_VALUE), + "word affixes count again" to TunableSimilarity(ignoreWordAffixes = false), + "measured against raw length" to + TunableSimilarity(denominator = TunableSimilarity.Denominator.Longer), + "measured against the shorter password" to + TunableSimilarity(denominator = TunableSimilarity.Denominator.Shorter), + "both reverted, old thresholds" to TunableSimilarity( + ignoreWordAffixes = false, + denominator = TunableSimilarity.Denominator.Shorter, + affixShare = 0.3, + wordAffixShare = 0.55, + ), + "current + minTemplateLength 10" to TunableSimilarity(minTemplateLength = 10), + "current + minAffixLength 5" to TunableSimilarity(minAffixLength = 5), + "current + maxDistanceRatio 6" to TunableSimilarity(maxDistanceRatio = 6), + ) + + private fun score(label: String, similarity: TunableSimilarity, corpora: List) { + var truePositives = 0L + var falsePositives = 0L + var truePairs = 0L + corpora.forEach { corpus -> + truePairs += corpus.variantPairs.size + similarity.edges(corpus.candidates()).keys.forEach { pair -> + when { + pair in corpus.variantPairs -> truePositives++ + pair in corpus.duplicatePairs -> Unit + else -> falsePositives++ + } + } + } + val precision = ratio(truePositives, truePositives + falsePositives) + val recall = ratio(truePositives, truePairs) + val f1 = if (precision + recall == 0.0) 0.0 + else 2 * precision * recall / (precision + recall) + line( + "%-46s %10s %10s %8.4f %12d".format( + label, pct(precision), pct(recall), f1, falsePositives, + ), + ) + } + + /** + * Recall variation by variation and by length, so a precision gain can be paid for + * knowingly. The length bucket is what shows where a rule that only bites on long + * passwords actually costs anything. + */ + private fun detail(label: String, similarity: TunableSimilarity, corpora: List) { + val byVariation = HashMap() + val byLength = HashMap() + val byShape = HashMap() + corpora.forEach { corpus -> + val flagged = similarity.edges(corpus.candidates()).keys + corpus.variantPairs.forEach { pair -> + val first = corpus.byId.getValue(pair.a) + val second = corpus.byId.getValue(pair.b) + val kind = when { + first.relation == "base" -> second.relation + second.relation == "base" -> first.relation + else -> "variant-of-variant" + } + val shorter = minOf(first.password.length, second.password.length) + val length = when { + shorter < 12 -> "under 12" + shorter < 16 -> "12 to 15" + shorter < 20 -> "16 to 19" + shorter < 28 -> "20 to 27 (cap bites)" + else -> "28 and over (cap bites)" + } + val hit = pair in flagged + byVariation.tally(kind, hit) + byLength.tally(length, hit) + byShape.tally(first.archetype, hit) + } + } + listOf( + "variation" to byVariation, + "length of the shorter password" to byLength, + "shape" to byShape, + ).forEach { (name, buckets) -> + line("-- recall by $name, $label --") + buckets.entries.sortedBy { it.key }.forEach { (kind, bucket) -> + line( + "%-26s %7d/%-7d %s".format( + kind, bucket[0], bucket[1], pct(ratio(bucket[0], bucket[1])), + ), + ) + } + } + line("") + } + + private fun HashMap.tally(key: String, hit: Boolean) { + val bucket = getOrPut(key) { LongArray(2) } + bucket[1]++ + if (hit) bucket[0]++ + } + + private fun header() = line( + "%-46s %10s %10s %8s %12s".format("setting", "precision", "recall", "F1", "false pairs"), + ) + + private fun line(text: String) { + report.append(text).append('\n') + } + + private fun ratio(numerator: Number, denominator: Number): Double { + val d = denominator.toDouble() + return if (d == 0.0) 0.0 else numerator.toDouble() / d + } + + private fun pct(value: Double) = "%.2f%%".format(value * 100) +} diff --git a/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/domain/checker/TunableSimilarity.kt b/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/domain/checker/TunableSimilarity.kt new file mode 100644 index 000000000..eccf1c283 --- /dev/null +++ b/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/domain/checker/TunableSimilarity.kt @@ -0,0 +1,207 @@ +package de.davis.keygo.feature.password_health.domain.checker + +import de.davis.keygo.feature.password_health.domain.model.PasswordCandidate +import kotlin.math.abs +import kotlin.math.max +import kotlin.math.min + +/** + * A copy of [SimilarPasswordChecker]'s decision with its constants opened up, so the + * operating point can be moved and measured without touching the shipped checker. + * + * Only the decision is duplicated. Everything it leans on (canonicalisation, the + * character classification in [PasswordShape], the word model, the bounded distance) is + * the real code from PasswordSimilarity.kt, and + * `SimilaritySensitivitySweep.replicaMatchesShippedChecker` pins the default settings to + * the shipped output, so a drift in either one fails. + */ +internal class TunableSimilarity( + private val minLength: Int = 6, + private val minTemplateLength: Int = 8, + private val minAffixLength: Int = 4, + /** Zero switches the containment rule off entirely. */ + private val minContainedLength: Int = 8, + /** How far a number may travel and still read as a bump. Int.MAX_VALUE switches it off. */ + private val maxCounterStep: Long = 3, + private val minSharedWords: Int = 1, + private val sharedWordsAsEvidence: Int = 2, + /** What shared words alone are worth. 1 is the old behaviour: the full budget. */ + private val weakEvidenceDivisor: Int = 1, + private val affixShare: Double = 0.4, + private val wordAffixShare: Double = 0.55, + private val maxDistanceRatio: Int = 4, + /** A ceiling on the edit budget however long the passwords are. */ + private val maxDistance: Int = 4, + private val denominator: Denominator = Denominator.LongerSecret, + /** Whether an end that reads as words on both sides stops counting entirely. */ + private val ignoreWordAffixes: Boolean = true, + /** + * Whether a pair the edit budget accepts still has to share something, or differ only + * in free filler, before it is reported. Switching this off is what the edit rule used + * to do, and it produced most of the false pairs. + */ + private val requireEvidence: Boolean = true, + /** + * Whether the two ends are stopped from claiming the same characters twice. Without it + * a short password whose prefix and suffix overlap can score above 1.0 on its own + * length alone. + */ + private val capOverlap: Boolean = false, +) { + + enum class Denominator { Shorter, Longer, Mean, LongerSecret } + + /** Which rule fired for a flagged pair. */ + enum class Rule { LeetFamily, Contains, Template, Distance } + + fun edges(candidates: List): Map { + val shapes = candidates.map { it to PasswordShape(it.password) } + val comparable = shapes.filter { (_, shape) -> shape.size >= minLength } + val edges = LinkedHashMap() + + comparable.groupBy { (_, shape) -> CharArrayKey(shape.canonical) } + .values + .filter { family -> family.distinctBy { CharArrayKey(it.first.password) }.size > 1 } + .forEach { family -> + for (i in family.indices) for (j in i + 1..family.lastIndex) + edges[PairKey.of(family[i].first.id, family[j].first.id)] = Rule.LeetFamily + } + + val rows = DistanceRows(comparable.maxOfOrNull { (_, shape) -> shape.size } ?: 0) + for (i in comparable.indices) { + val (first, firstShape) = comparable[i] + for (j in i + 1..comparable.lastIndex) { + val (second, secondShape) = comparable[j] + if (first.password.contentEquals(second.password)) continue + if (firstShape.canonical.contentEquals(secondShape.canonical)) continue + + val rule = rule(firstShape, secondShape, rows) + if (rule != null) edges.putIfAbsent(PairKey.of(first.id, second.id), rule) + } + } + return edges + } + + private fun rule(first: PasswordShape, second: PasswordShape, rows: DistanceRows): Rule? { + val shorter = min(first.size, second.size) + val prefix = commonPrefixLength(first.canonical, second.canonical) + val suffix = commonSuffixLength(first.canonical, second.canonical) + + if (minContainedLength > 0 && extendsTheOther(first, second, prefix, suffix)) + return Rule.Contains + if (sharesTemplate(first, second, prefix, suffix, shorter)) return Rule.Template + + val allowed = min(max(1, shorter / maxDistanceRatio), maxDistance) + val budget = if (requireEvidence) editBudget(first, second, prefix, suffix, allowed) + else allowed + if (budget <= 0) return null + + return if (boundedDistance(first.canonical, second.canonical, budget, rows) <= budget) + Rule.Distance + else null + } + + private fun extendsTheOther( + first: PasswordShape, + second: PasswordShape, + prefix: Int, + suffix: Int, + ): Boolean { + val shorter = min(first.size, second.size) + if (shorter < minContainedLength) return false + if (first.size == second.size) return false + + val longer = if (first.size > second.size) first else second + if (prefix >= shorter) return longer.startsRun(shorter) + if (suffix >= shorter) return longer.startsRun(longer.size - shorter) + return false + } + + private fun editBudget( + first: PasswordShape, + second: PasswordShape, + prefix: Int, + suffix: Int, + allowed: Int, + ): Int { + val firstEnd = max(prefix, first.size - suffix) + val secondEnd = max(prefix, second.size - suffix) + val before = first.numberAround(prefix, firstEnd) + val after = second.numberAround(prefix, secondEnd) + if (before >= 0 && after >= 0 && abs(before - after) > maxCounterStep) return 0 + + val shared = prefixAffix(first, second, prefix).length + + suffixAffix(first, second, suffix).length + if (shared >= minAffixLength) return allowed + + val words = min(sharedWords(first, prefix, suffix), sharedWords(second, prefix, suffix)) + if (words < minSharedWords) return 0 + + val oneStep = first.holdsNothingToCrack(prefix, firstEnd) && + second.holdsNothingToCrack(prefix, secondEnd) || + first.movesOneSlot(prefix, firstEnd) && second.movesOneSlot(prefix, secondEnd) + if (oneStep) return allowed + + return if (words >= sharedWordsAsEvidence) max(1, allowed / weakEvidenceDivisor) else 0 + } + + private fun sharedWords(shape: PasswordShape, prefix: Int, suffix: Int): Int { + val tail = max(prefix, shape.size - suffix) + return shape.wordsWithin(0, prefix) + shape.wordsWithin(tail, shape.size) + } + + /** One shared end: what it is worth, and whether it reads as words on both sides. */ + private class Affix(val length: Int, val isWord: Boolean) + + private fun prefixAffix(first: PasswordShape, second: PasswordShape, prefix: Int): Affix { + val isWord = first.readsAsWords(0, prefix) && second.readsAsWords(0, prefix) + val length = if (ignoreWordAffixes && isWord) 0 + else min(first.secretLength(0, prefix), second.secretLength(0, prefix)) + return Affix(length, isWord) + } + + private fun suffixAffix(first: PasswordShape, second: PasswordShape, suffix: Int): Affix { + val isWord = first.readsAsWords(first.size - suffix, first.size) && + second.readsAsWords(second.size - suffix, second.size) + val length = if (ignoreWordAffixes && isWord) 0 + else min( + first.secretLength(first.size - suffix, first.size), + second.secretLength(second.size - suffix, second.size), + ) + return Affix(length, isWord) + } + + private fun sharesTemplate( + first: PasswordShape, + second: PasswordShape, + prefix: Int, + suffix: Int, + shorter: Int, + ): Boolean { + if (shorter < minTemplateLength) return false + + val head = prefixAffix(first, second, prefix) + val tail = suffixAffix(first, second, suffix) + // Zeroed before the length gate, the way the checker does it: an end that counts + // for nothing must not carry the pair past the minimum either. + val tailLength = if (capOverlap) min(tail.length, shorter - min(head.length, shorter)) + else tail.length + if (head.length + tailLength < minAffixLength) return false + + val longer = if (first.size >= second.size) first else second + val scale = when (denominator) { + Denominator.Shorter -> shorter.toDouble() + Denominator.Longer -> max(first.size, second.size).toDouble() + Denominator.Mean -> (first.size + second.size) / 2.0 + Denominator.LongerSecret -> longer.secretLength(0, longer.size).toDouble() + } + return contribution(head.length, scale, head.isWord) + + contribution(tailLength, scale, tail.isWord) >= 1.0 + } + + private fun contribution(length: Int, scale: Double, isWord: Boolean): Double = when { + isWord && ignoreWordAffixes -> 0.0 + scale <= 0.0 -> if (length > 0) Double.MAX_VALUE else 0.0 + else -> length / ((if (isWord) wordAffixShare else affixShare) * scale) + } +} diff --git a/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/domain/checker/WeakPasswordCheckerTest.kt b/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/domain/checker/WeakPasswordCheckerTest.kt new file mode 100644 index 000000000..9d9751a3b --- /dev/null +++ b/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/domain/checker/WeakPasswordCheckerTest.kt @@ -0,0 +1,91 @@ +package de.davis.keygo.feature.password_health.domain.checker + +import de.davis.keygo.core.item.domain.model.PasswordScore +import de.davis.keygo.feature.password_health.domain.model.CheckKind +import de.davis.keygo.feature.password_health.domain.model.HealthFinding +import de.davis.keygo.feature.password_health.domain.model.ItemIssue +import de.davis.keygo.feature.password_health.domain.model.PasswordCandidate +import kotlinx.coroutines.test.runTest +import java.util.UUID +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class WeakPasswordCheckerTest { + + private val checker = WeakPasswordChecker() + + @Test + fun reportsAsTheStrengthCheck() { + assertEquals(CheckKind.Strength, checker.type) + } + + @Test + fun flagsRidiculousAndWeakScores() = runTest { + val outcome = checker.check( + listOf( + candidate(0, PasswordScore.Ridiculous), + candidate(1, PasswordScore.Weak), + ), + ) + + assertEquals( + listOf( + HealthFinding.Item(id(0), ItemIssue.Weak(PasswordScore.Ridiculous)), + HealthFinding.Item(id(1), ItemIssue.Weak(PasswordScore.Weak)), + ), + outcome.findings, + ) + assertNull(outcome.gap) + } + + @Test + fun leavesModerateAndStrongerScoresAlone() = runTest { + val outcome = checker.check( + listOf( + candidate(0, PasswordScore.Moderate), + candidate(1, PasswordScore.Strong), + candidate(2, PasswordScore.Excellent), + ), + ) + + assertTrue(outcome.findings.isEmpty()) + } + + @Test + fun anUnscoredPasswordIsNotCalledWeak() = runTest { + val outcome = checker.check(listOf(candidate(0, PasswordScore.None))) + + assertTrue(outcome.findings.isEmpty()) + } + + @Test + fun onlyTheWeakOnesOfAMixedVaultAreFlagged() = runTest { + val outcome = checker.check( + listOf( + candidate(0, PasswordScore.Strong), + candidate(1, PasswordScore.Weak), + candidate(2, PasswordScore.Excellent), + ), + ) + + assertEquals( + listOf(id(1)), + outcome.findings.map { (it as HealthFinding.Item).id }, + ) + } + + @Test + fun nothingToCheckFindsNothing() = runTest { + val outcome = checker.check(emptyList()) + + assertTrue(outcome.findings.isEmpty()) + assertNull(outcome.gap) + } + + private fun id(n: Int) = UUID(0L, n.toLong()) + + private fun candidate(n: Int, score: PasswordScore) = + PasswordCandidate(id = id(n), score = score, password = "pw-$n".toCharArray()) +} diff --git a/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/domain/checker/WordModelTest.kt b/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/domain/checker/WordModelTest.kt new file mode 100644 index 000000000..c124a8686 --- /dev/null +++ b/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/domain/checker/WordModelTest.kt @@ -0,0 +1,154 @@ +package de.davis.keygo.feature.password_health.domain.checker + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * The model replaced a list of English words, so the tests that matter are the ones a + * list could not have passed: a German, French or Spanish word it was never taught. + */ +class WordModelTest { + + private fun wordAt(text: String, from: Int = 0): Int { + val raw = text.toCharArray() + return WordModel.wordRunAt(raw.canonicalize(), raw, from, raw.size) + } + + // region words a list would have held + + @Test + fun readsAPlainEnglishWord() = assertEquals(6, wordAt("dragon")) + + @Test + fun readsAShoutedWord() = assertEquals(6, wordAt("HARBOR")) + + @Test + fun readsALeetedWord() = assertEquals(6, wordAt("Dr@gon")) + + // endregion + + // region words no English list holds + + @Test + fun readsAGermanWordItWasNeverTaught() = assertEquals(9, wordAt("kartoffel")) + + @Test + fun readsALeetedGermanWord() = assertEquals(9, wordAt("K@rtoffel")) + + @Test + fun readsAFrenchWordItWasNeverTaught() = assertEquals(8, wordAt("papillon")) + + @Test + fun readsAnItalianWordItWasNeverTaught() = assertEquals(8, wordAt("farfalla")) + + /** + * How much of a word survives, language by language, which is the claim the whole + * change rests on. Reading a word most of the way through is enough for what the + * checker asks; reading it whole matters only for counting words. + * + * Measured: English 100%, Spanish 100%, French 96%, Italian 95%, German 76%, Polish + * 61%. Polish is the floor because its spelling is the furthest from the training set. + * The English list this replaced read 0% of every word below that is not English. + */ + @Test + fun readsMostOfAWordInEveryLanguageAroundEnglish() { + val byLanguage = mapOf( + "english" to listOf( + "dragon", "harbor", "summer", "monkey", "sunshine", "butterfly", + "freedom", "mountain", "chocolate", "elephant", "whisper", "garden", + ), + "german" to listOf( + "kartoffel", "sonnenschein", "freiheit", "schmetterling", "blumenwiese", + "hoffnung", "wasserfall", "geburtstag", "fernweh", "morgenrot", + ), + "french" to listOf( + "papillon", "bonjour", "liberte", "chocolat", "printemps", "bibliotheque", + "tournesol", "pamplemousse", "brouillard", "chaussure", + ), + "spanish" to listOf( + "mariposa", "primavera", "libertad", "corazon", "ventana", "estrella", + "murcielago", "zanahoria", "esperanza", "caballero", + ), + "italian" to listOf( + "farfalla", "amicizia", "montagna", "girasole", "biblioteca", + "cioccolato", "finestra", "speranza", "tramonto", + ), + "polish" to listOf( + "przyjaciel", "wolnosc", "motyl", "sloneczny", "biblioteka", "marzenie", + ), + ) + + byLanguage.forEach { (language, words) -> + val read = words.sumOf { wordAt(it) }.toDouble() / words.sumOf { it.length } + assertTrue(read >= 0.60, "only ${"%.0f".format(read * 100)}% of $language read") + } + } + + // endregion + + // region what an attacker still has to crack + + @Test + fun rejectsAShortRandomToken() = assertEquals(0, wordAt("Kd8!")) + + @Test + fun rejectsALongerRandomToken() = assertEquals(0, wordAt("b7Kqxz")) + + @Test + fun rejectsAKeyboardWalk() = assertEquals(0, wordAt("4rfv5tgb")) + + @Test + fun rejectsARunTooShortToBeAWord() = assertEquals(0, wordAt("ab")) + + /** "1337" canonicalises to "ieet". One real letter is what tells them apart. */ + @Test + fun rejectsDigitsThatFoldIntoLetters() = assertEquals(0, wordAt("1337")) + + /** + * What a model costs that a list did not: "trewq" is spelled the way words are and is + * not one. The price is paid back by every word of every language a list never held, + * and `SimilarPasswordCheckerBenchmark` weighs the two against each other. + */ + @Test + fun admitsASpellingThatIsNotAWord() = assertEquals(5, wordAt("trewq")) + + // endregion + + // region where a word ends + + @Test + fun stopsWhereTheLettersStop() = assertEquals(6, wordAt("dragon2019")) + + @Test + fun readsTheWordStartingAtTheOffsetItIsGiven() = assertEquals(6, wordAt("2019dragon", from = 4)) + + /** + * Canonicalising turns "1993+" into five more letters, so the run is eleven long and + * only the model says where the word inside it stopped. + */ + @Test + fun stopsWhereTheWordEndsInsideALongerRunOfLetters() = + assertEquals(6, wordAt("Harbor1993+")) + + /** + * The other thing a model costs. Two words written without anything between them read + * as one long word, because only a list knew where the first one ended. + * + * The right answer about cost, since all twelve characters are free either way, and + * the wrong answer about count: `PasswordShape.wordsWithin` sees one word where a + * person wrote two. That is why [PasswordShape] tries shape before the model at all, + * since "MeadowMeadow" as a person would usually write it splits on the capital. + */ + @Test + fun readsTwoWordsRunTogetherAsOne() = assertEquals(12, wordAt("meadowmeadow")) + + /** + * A run written in an alphabet the model cannot score is still letters in a row, and + * letters in a row in a password are a word far more often than they are a secret. + */ + @Test + fun keepsARunItHasNoWayToScore() = assertEquals(6, wordAt("пароль")) + + // endregion +} diff --git a/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/domain/model/ItemHealthTest.kt b/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/domain/model/ItemHealthTest.kt new file mode 100644 index 000000000..262b8c74b --- /dev/null +++ b/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/domain/model/ItemHealthTest.kt @@ -0,0 +1,82 @@ +package de.davis.keygo.feature.password_health.domain.model + +import de.davis.keygo.core.item.domain.model.PasswordScore +import java.util.UUID +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class ItemHealthTest { + + @Test + fun anItemWithoutIssuesHasNoSeverity() { + val health = health() + + assertNull(health.maxSeverity) + assertNull(health.breach) + assertNull(health.weak) + } + + @Test + fun aWeakPasswordIsMedium() { + val health = health(ItemIssue.Weak(PasswordScore.Weak)) + + assertEquals(FindingSeverity.Medium, health.maxSeverity) + assertEquals(ItemIssue.Weak(PasswordScore.Weak), health.weak) + assertNull(health.breach) + } + + @Test + fun aBreachedPasswordIsCritical() { + val health = health(ItemIssue.Breached(5)) + + assertEquals(FindingSeverity.Critical, health.maxSeverity) + assertEquals(ItemIssue.Breached(5), health.breach) + assertNull(health.weak) + } + + @Test + fun theGravestIssueSetsTheSeverity() { + val health = health(ItemIssue.Weak(PasswordScore.Ridiculous), ItemIssue.Breached(1)) + + assertEquals(FindingSeverity.Critical, health.maxSeverity) + assertEquals(ItemIssue.Breached(1), health.breach) + assertEquals(ItemIssue.Weak(PasswordScore.Ridiculous), health.weak) + } + + @Test + fun severitiesRankMediumBelowHighBelowCritical() { + assertEquals( + listOf(FindingSeverity.Medium, FindingSeverity.High, FindingSeverity.Critical), + FindingSeverity.entries.sorted(), + ) + } + + @Test + fun reuseIsGraverThanSimilarity() { + assertEquals(FindingSeverity.High, RelationType.Reused.severity) + assertEquals(FindingSeverity.Medium, RelationType.Similar.severity) + } + + @Test + fun aSkippedCheckLeavesEveryCandidateUnchecked() { + val candidates = List(3) { + PasswordCandidate(UUID(0L, it.toLong()), PasswordScore.Strong, charArrayOf('x')) + } + + val outcome = CheckOutcome.skipped(GapReason.Disabled, candidates) + + assertEquals(emptyList(), outcome.findings) + assertEquals( + CheckGap(GapReason.Disabled, candidates.mapTo(mutableSetOf()) { it.id }), + outcome.gap, + ) + } + + private fun health(vararg issues: ItemIssue) = ItemHealth( + itemId = UUID(0L, 0L), + title = "item", + username = null, + issues = issues.toList(), + ) +} diff --git a/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/domain/model/RelatedGroupTest.kt b/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/domain/model/RelatedGroupTest.kt new file mode 100644 index 000000000..673bdb8d9 --- /dev/null +++ b/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/domain/model/RelatedGroupTest.kt @@ -0,0 +1,117 @@ +package de.davis.keygo.feature.password_health.domain.model + +import de.davis.keygo.core.item.domain.alias.ItemId +import de.davis.keygo.core.item.domain.model.PasswordScore +import java.util.UUID +import kotlin.test.Test +import kotlin.test.assertEquals + +/** + * A group is one connected component over every relation edge, so a handful of copies + * can sit inside a much larger similarity chain. What the card says about it has to be + * what is mostly true of it. + */ +class RelatedGroupTest { + + private val ids = List(6) { UUID(0L, it.toLong()) } + + @Test + fun namesTheRelationThatCoversMostOfTheGroup() { + val group = group( + relation(RelationType.Reused, ids[0], ids[1]), + relation(RelationType.Similar, ids[1], ids[2], ids[3], ids[4], ids[5]), + ) + + assertEquals(RelationType.Similar, group.dominantRelation) + } + + @Test + fun namesReuseWhenReuseIsWhatCoversTheGroup() { + val group = group( + relation(RelationType.Reused, ids[0], ids[1], ids[2], ids[3]), + relation(RelationType.Similar, ids[3], ids[4]), + ) + + assertEquals(RelationType.Reused, group.dominantRelation) + } + + /** A tie goes to the finding worth acting on first. */ + @Test + fun namesTheGraverRelationWhenBothCoverTheSameCount() { + val group = group( + relation(RelationType.Reused, ids[0], ids[1], ids[2]), + relation(RelationType.Similar, ids[3], ids[4], ids[5]), + ) + + assertEquals(RelationType.Reused, group.dominantRelation) + } + + @Test + fun keepsTheSeverityOfTheGravestRelationInIt() { + val group = group( + relation(RelationType.Reused, ids[0], ids[1]), + relation(RelationType.Similar, ids[1], ids[2], ids[3], ids[4], ids[5]), + ) + + assertEquals(FindingSeverity.High, group.maxSeverity) + } + + @Test + fun aBreachedMemberMakesTheGroupCritical() { + val group = RelatedGroup( + id = ids[0], + members = setOf(item(ids[0], ItemIssue.Breached(1)), item(ids[1])), + relations = setOf(relation(RelationType.Similar, ids[0], ids[1])), + ) + + assertEquals(FindingSeverity.Critical, group.maxSeverity) + } + + @Test + fun aGroupWithoutMemberIssuesTakesTheSeverityOfItsRelation() { + val group = RelatedGroup( + id = ids[0], + members = setOf(item(ids[0]), item(ids[1])), + relations = setOf(relation(RelationType.Similar, ids[0], ids[1])), + ) + + assertEquals(FindingSeverity.Medium, group.maxSeverity) + } + + @Test + fun ordersTheGravestMemberFirstAndTheCleanOnesLast() { + val group = RelatedGroup( + id = ids[0], + members = setOf( + item(ids[0]), + item(ids[1], ItemIssue.Weak(PasswordScore.Weak)), + item(ids[2], ItemIssue.Breached(9)), + ), + relations = setOf(relation(RelationType.Reused, ids[0], ids[1], ids[2])), + ) + + assertEquals(listOf(ids[2], ids[1], ids[0]), group.orderedMembers.map { it.itemId }) + } + + @Test + fun aSingleRelationNamesTheGroup() { + assertEquals( + RelationType.Similar, + group(relation(RelationType.Similar, ids[0], ids[1])).dominantRelation, + ) + } + + private fun item(id: ItemId, vararg issues: ItemIssue) = + ItemHealth(itemId = id, title = "item", username = null, issues = issues.toList()) + + private fun group(vararg relations: HealthFinding.Relation) = RelatedGroup( + id = ids.first(), + members = ids.mapTo(mutableSetOf()) { + ItemHealth(itemId = it, title = "item", username = null, issues = emptyList()) + }, + relations = relations.toSet(), + ) + + private fun relation(type: RelationType, vararg members: ItemId) = + HealthFinding.Relation(relatedItemIds = members.toSet(), type = type) +} diff --git a/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/domain/model/StoredHealthReportTest.kt b/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/domain/model/StoredHealthReportTest.kt new file mode 100644 index 000000000..a41320a58 --- /dev/null +++ b/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/domain/model/StoredHealthReportTest.kt @@ -0,0 +1,211 @@ +package de.davis.keygo.feature.password_health.domain.model + +import de.davis.keygo.feature.password_health.domain.report.breach +import de.davis.keygo.feature.password_health.domain.report.fingerprint +import de.davis.keygo.feature.password_health.domain.report.id +import de.davis.keygo.feature.password_health.domain.report.storedItem +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue +import kotlin.time.Duration.Companion.days +import kotlin.time.Duration.Companion.nanoseconds +import kotlin.time.Duration.Companion.seconds +import kotlin.time.Instant + +class StoredHealthReportTest { + + private val checkedAt = Instant.fromEpochSeconds(1_000_000) + private val ids = List(2) { id(it) } + private val fingerprints = ids.associateWith(::fingerprint) + private val report = StoredHealthReport( + items = ids.map { storedItem(it, breach = breach(0, checkedAt)) }, + relationalFindings = emptyList(), + breachCheckEnabled = true, + ) + + @Test + fun isFreshForTheSameLoginsWhileTheBreachLookupsHold() { + assertTrue(report.isFreshFor(fingerprints, true, checkedAt + 1.seconds)) + } + + @Test + fun isStaleOnceABreachLookupRunsOut() { + assertFalse(report.isFreshFor(fingerprints, true, checkedAt + BreachResult.TTL)) + } + + @Test + fun anExpiredBreachLookupLeavesTheOtherChecksCurrent() { + assertTrue(report.isCurrentFor(fingerprints, true)) + } + + @Test + fun withoutTheBreachCheckItNeverExpires() { + val disabled = report.copy( + items = ids.map { storedItem(it) }, + gaps = mapOf(CheckKind.Breach to CheckGap(GapReason.Disabled, ids.toSet())), + breachCheckEnabled = false, + ) + + assertTrue(disabled.isFreshFor(fingerprints, false, checkedAt + 365.days)) + } + + @Test + fun isStaleWhenAPasswordChanged() { + val changed = fingerprints + (ids[0] to HealthFingerprint(byteArrayOf(1))) + + assertFalse(report.isCurrentFor(changed, true)) + } + + @Test + fun isStaleWhenALoginWasAdded() { + val added = fingerprints + (id(9) to fingerprint(id(9))) + + assertFalse(report.isCurrentFor(added, true)) + } + + @Test + fun isStaleWhenTheBreachCheckWasToggled() { + assertFalse(report.isCurrentFor(fingerprints, false)) + } + + @Test + fun isStaleWhileAPasswordCouldNotBeRead() { + val unreadable = report.copy(items = listOf(storedItem(ids[0], unreadable = true))) + + assertFalse(unreadable.isCurrentFor(mapOf(ids[0] to fingerprint(ids[0])), true)) + } + + @Test + fun anUnreachableBreachLookupIsRetriedWithoutTheOtherChecks() { + val incomplete = report.copy( + items = listOf(storedItem(ids[0]), report.items[1]), + gaps = mapOf(CheckKind.Breach to CheckGap(GapReason.Unreachable, setOf(ids[0]))), + ) + + assertFalse(incomplete.isFreshFor(fingerprints, true, checkedAt)) + assertTrue(incomplete.isCurrentFor(fingerprints, true)) + } + + @Test + fun isStaleWhenAnotherCheckFailed() { + val incomplete = report.copy( + gaps = mapOf(CheckKind.Similarity to CheckGap(GapReason.Failed, setOf(ids[0]))), + ) + + assertFalse(incomplete.isCurrentFor(fingerprints, true)) + } + + @Test + fun breachResultsOnlyCarryOverForUnchangedPasswordsStillInDate() { + val mixed = report.copy( + items = listOf( + storedItem(ids[0], breach = breach(2, checkedAt)), + storedItem(ids[1], breach = breach(0, checkedAt - BreachResult.TTL)), + storedItem(id(2), breach = breach(5, checkedAt)), + ), + ) + val current = fingerprints + (id(2) to HealthFingerprint(byteArrayOf(1))) + + assertEquals( + mapOf(ids[0] to breach(2, checkedAt)), + mixed.breachResultsAt(current, checkedAt) + ) + } + + @Test + fun aBreachLookupHoldsUntilJustBeforeItsTtl() { + val result = breach(0, checkedAt) + + assertTrue(result.isCurrentAt(checkedAt + BreachResult.TTL - 1.nanoseconds)) + assertFalse(result.isCurrentAt(checkedAt + BreachResult.TTL)) + } + + @Test + fun aBreachLookupFromTheFutureStillHolds() { + assertTrue(breach(0, checkedAt).isCurrentAt(checkedAt - 1.days)) + } + + @Test + fun isStaleWhileAPasswordWasNeverLookedUp() { + val unchecked = report.copy(items = listOf(storedItem(ids[0]), report.items[1])) + + assertFalse(unchecked.isFreshFor(fingerprints, true, checkedAt)) + } + + @Test + fun isStaleWhenALoginWasRemoved() { + assertFalse(report.isCurrentFor(mapOf(ids[0] to fingerprint(ids[0])), true)) + } + + @Test + fun isStaleWhenTheBreachCheckWasTurnedOn() { + val disabled = report.copy(items = ids.map { storedItem(it) }, breachCheckEnabled = false) + + assertFalse(disabled.isCurrentFor(fingerprints, true)) + } + + @Test + fun aDisabledCheckIsNoReasonToRescan() { + val skipped = report.copy( + gaps = mapOf(CheckKind.Similarity to CheckGap(GapReason.Disabled, setOf(ids[0]))), + ) + + assertTrue(skipped.isCurrentFor(fingerprints, true)) + } + + @Test + fun isStaleWhenAnotherCheckCouldNotReachItsService() { + val incomplete = report.copy( + gaps = mapOf(CheckKind.Strength to CheckGap(GapReason.Unreachable, setOf(ids[0]))), + ) + + assertFalse(incomplete.isCurrentFor(fingerprints, true)) + } + + @Test + fun aFailedBreachLookupIsRetriedWithoutTheOtherChecks() { + val incomplete = report.copy( + gaps = mapOf(CheckKind.Breach to CheckGap(GapReason.Failed, setOf(ids[0]))), + ) + + assertTrue(incomplete.isCurrentFor(fingerprints, true)) + } + + @Test + fun anEmptyReportIsCurrentForAnEmptyVault() { + val empty = report.copy(items = emptyList()) + + assertTrue(empty.isFreshFor(emptyMap(), true, checkedAt)) + } + + @Test + fun breachResultsSkipItemsThatWereNeverLookedUp() { + val unchecked = report.copy(items = listOf(storedItem(ids[0]))) + + assertEquals(emptyMap(), unchecked.breachResultsAt(fingerprints, checkedAt)) + } + + @Test + fun breachResultsSkipLoginsThatAreGone() { + assertEquals( + mapOf(ids[1] to breach(0, checkedAt)), + report.breachResultsAt(mapOf(ids[1] to fingerprint(ids[1])), checkedAt), + ) + } + + @Test + fun countsEveryItemAsScanned() { + assertEquals(2, report.totalPasswordsScanned) + } + + @Test + fun fingerprintsCompareByContent() { + assertEquals(HealthFingerprint(byteArrayOf(1, 2)), HealthFingerprint(byteArrayOf(1, 2))) + assertEquals( + HealthFingerprint(byteArrayOf(1, 2)).hashCode(), + HealthFingerprint(byteArrayOf(1, 2)).hashCode(), + ) + assertFalse(HealthFingerprint(byteArrayOf(1, 2)) == HealthFingerprint(byteArrayOf(2, 1))) + } +} diff --git a/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/domain/model/VaultFingerprintTest.kt b/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/domain/model/VaultFingerprintTest.kt new file mode 100644 index 000000000..eda9a3d27 --- /dev/null +++ b/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/domain/model/VaultFingerprintTest.kt @@ -0,0 +1,27 @@ +package de.davis.keygo.feature.password_health.domain.model + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotEquals + +class VaultFingerprintTest { + + private val a = HealthFingerprint(byteArrayOf(1)) + private val b = HealthFingerprint(byteArrayOf(-1)) + private val c = HealthFingerprint(byteArrayOf(2)) + + @Test + fun orderDoesNotMatter() { + assertEquals(VaultFingerprint.of(listOf(a, b, c)), VaultFingerprint.of(listOf(c, a, b))) + } + + @Test + fun aChangedPasswordChangesIt() { + assertNotEquals(VaultFingerprint.of(listOf(a, b)), VaultFingerprint.of(listOf(a, c))) + } + + @Test + fun anAddedPasswordChangesIt() { + assertNotEquals(VaultFingerprint.of(listOf(a, b)), VaultFingerprint.of(listOf(a, b, c))) + } +} diff --git a/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/domain/report/HealthReportAssemblerTest.kt b/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/domain/report/HealthReportAssemblerTest.kt new file mode 100644 index 000000000..26c881f3e --- /dev/null +++ b/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/domain/report/HealthReportAssemblerTest.kt @@ -0,0 +1,283 @@ +package de.davis.keygo.feature.password_health.domain.report + +import de.davis.keygo.core.item.domain.model.DomainInfo +import de.davis.keygo.core.item.domain.model.PasswordScore +import de.davis.keygo.feature.password_health.domain.model.CheckGap +import de.davis.keygo.feature.password_health.domain.model.CheckKind +import de.davis.keygo.feature.password_health.domain.model.GapReason +import de.davis.keygo.feature.password_health.domain.model.HealthFinding +import de.davis.keygo.feature.password_health.domain.model.ItemIssue +import de.davis.keygo.feature.password_health.domain.model.RelationType +import de.davis.keygo.feature.password_health.domain.model.StoredHealthItem +import de.davis.keygo.feature.password_health.domain.model.StoredHealthReport +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class HealthReportAssemblerTest { + + private val assembler = HealthReportAssembler() + + @Test + fun relationsThatShareAnItemFormOneGroup() { + val report = report( + items = (0..3).map { storedItem(id(it)) }, + relations = listOf( + relation(RelationType.Reused, 0, 1), + relation(RelationType.Similar, 1, 2), + ), + ) + + val assembled = assembler.assemble(report, logins(0..3)) + + assertEquals(1, assembled.groups.size) + assertEquals( + setOf(id(0), id(1), id(2)), + assembled.groups.single().members.mapTo(mutableSetOf()) { it.itemId }, + ) + assertEquals(2, assembled.groups.single().relations.size) + } + + @Test + fun standaloneOnlyListsItemsWithAnIssue() { + val report = report( + items = listOf( + storedItem(id(0), score = PasswordScore.Weak), + storedItem(id(1)), + storedItem(id(2), breach = breach(3)), + ), + ) + + val assembled = assembler.assemble(report, logins(0..2)) + + assertEquals(listOf(id(2), id(0)), assembled.standalone.map { it.itemId }) + assertEquals( + listOf(ItemIssue.Breached(3)), + assembled.standalone.first().issues, + ) + } + + @Test + fun aRelationLeftWithOneLoginIsDropped() { + val report = report( + items = listOf(storedItem(id(0)), storedItem(id(1))), + relations = listOf(relation(RelationType.Reused, 0, 1)), + ) + + val assembled = assembler.assemble(report, logins(0..0)) + + assertEquals(emptyList(), assembled.groups) + } + + @Test + fun aRelationKeepsTheLoginsThatStillExist() { + val report = report( + items = (0..2).map { storedItem(id(it)) }, + relations = listOf(relation(RelationType.Reused, 0, 1, 2)), + ) + + val assembled = assembler.assemble(report, logins(0..1)) + + assertEquals( + setOf(id(0), id(1)), + assembled.groups.single().relations.single().relatedItemIds, + ) + } + + @Test + fun aDeletedLoginLeavesNoTrace() { + val report = report( + items = listOf( + storedItem(id(0), breach = breach(1), unreadable = true), + storedItem(id(1), breach = breach(1), unreadable = true), + ), + gaps = mapOf( + CheckKind.Breach to CheckGap(GapReason.Unreachable, setOf(id(0))), + CheckKind.Similarity to CheckGap(GapReason.Failed, setOf(id(0), id(1))), + ), + ) + + val assembled = assembler.assemble(report, logins(1..1)) + + assertEquals(listOf(id(1)), assembled.standalone.map { it.itemId }) + assertEquals(setOf(id(1)), assembled.unreadable) + assertEquals( + mapOf( + CheckKind.Similarity to CheckGap(GapReason.Failed, setOf(id(1))), + ), + assembled.gaps, + ) + } + + @Test + fun itemsCarryTheLoginTheyDescribe() { + val report = report(items = listOf(storedItem(id(0), score = PasswordScore.Weak))) + val login = login(id(0)).copy( + name = "Mail", + username = "me@example.com", + domainInfos = setOf(DomainInfo(value = "mail.example.com", eTLD1 = "example.com")), + ) + + val item = assembler.assemble(report, listOf(login)).standalone.single() + + assertEquals("Mail", item.title) + assertEquals("me@example.com", item.username) + assertEquals(listOf("mail.example.com"), item.urls) + } + + @Test + fun aWeakBreachedPasswordListsTheBreachFirst() { + val report = report( + items = listOf(storedItem(id(0), score = PasswordScore.Ridiculous, breach = breach(2))), + ) + + assertEquals( + listOf(ItemIssue.Breached(2), ItemIssue.Weak(PasswordScore.Ridiculous)), + assembler.assemble(report, logins(0..0)).standalone.single().issues, + ) + } + + @Test + fun aCleanBreachLookupIsNoIssue() { + val report = report(items = listOf(storedItem(id(0), breach = breach(0)))) + + assertEquals(emptyList(), assembler.assemble(report, logins(0..0)).standalone) + } + + @Test + fun aNoneScoreIsNoIssue() { + val report = report(items = listOf(storedItem(id(0), score = PasswordScore.None))) + + assertEquals(emptyList(), assembler.assemble(report, logins(0..0)).standalone) + } + + @Test + fun aRelatedItemIsInItsGroupNotStandalone() { + val report = report( + items = listOf(storedItem(id(0), breach = breach(1)), storedItem(id(1))), + relations = listOf(relation(RelationType.Reused, 0, 1)), + ) + + val assembled = assembler.assemble(report, logins(0..1)) + + assertEquals(emptyList(), assembled.standalone) + assertEquals( + listOf(ItemIssue.Breached(1)), + assembled.groups.single().members.single { it.itemId == id(0) }.issues, + ) + } + + @Test + fun aGroupKeepsItsCleanMembers() { + val report = report( + items = listOf(storedItem(id(0)), storedItem(id(1))), + relations = listOf(relation(RelationType.Similar, 0, 1)), + ) + + val group = assembler.assemble(report, logins(0..1)).groups.single() + + assertEquals(setOf(id(0), id(1)), group.members.mapTo(mutableSetOf()) { it.itemId }) + assertTrue(group.id in setOf(id(0), id(1))) + } + + @Test + fun separateRelationsFormSeparateGroupsGravestFirst() { + val report = report( + items = (0..5).map { storedItem(id(it)) }.toMutableList().apply { + set(4, storedItem(id(4), breach = breach(1))) + }, + relations = listOf( + relation(RelationType.Similar, 0, 1), + relation(RelationType.Reused, 2, 3), + relation(RelationType.Similar, 4, 5), + ), + ) + + val groups = assembler.assemble(report, logins(0..5)).groups + + assertEquals( + listOf(setOf(id(4), id(5)), setOf(id(2), id(3)), setOf(id(0), id(1))), + groups.map { group -> group.members.mapTo(mutableSetOf()) { it.itemId } }, + ) + } + + @Test + fun standaloneItemsAreGravestFirst() { + val report = report( + items = listOf( + storedItem(id(0), score = PasswordScore.Weak), + storedItem(id(1), breach = breach(4)), + storedItem(id(2), score = PasswordScore.Ridiculous), + ), + ) + + assertEquals( + id(1), + assembler.assemble(report, logins(0..2)).standalone.first().itemId, + ) + } + + @Test + fun aLoginAddedSinceTheScanIsLeftOut() { + val report = report(items = listOf(storedItem(id(0), score = PasswordScore.Weak))) + + val assembled = assembler.assemble(report, logins(0..1)) + + assertEquals(listOf(id(0)), assembled.standalone.map { it.itemId }) + assertEquals(1, assembled.totalPasswordsScanned) + } + + @Test + fun gapsAndUnreadableItemsAreCarriedOver() { + val gap = CheckGap(GapReason.Unreachable, setOf(id(0), id(1))) + val report = report( + items = listOf(storedItem(id(0), unreadable = true), storedItem(id(1))), + gaps = mapOf(CheckKind.Breach to gap), + ) + + val assembled = assembler.assemble(report, logins(0..1)) + + assertEquals(mapOf(CheckKind.Breach to gap), assembled.gaps) + assertEquals(setOf(id(0)), assembled.unreadable) + } + + @Test + fun aGapLeftWithoutLoginsIsDropped() { + val report = report( + items = listOf(storedItem(id(0)), storedItem(id(1))), + gaps = mapOf(CheckKind.Breach to CheckGap(GapReason.Failed, setOf(id(0)))), + ) + + assertEquals(emptyMap(), assembler.assemble(report, logins(1..1)).gaps) + } + + @Test + fun aCleanVaultAssemblesToNothing() { + val report = report(items = (0..2).map { storedItem(id(it)) }) + + val assembled = assembler.assemble(report, logins(0..2)) + + assertEquals(emptyList(), assembled.groups) + assertEquals(emptyList(), assembled.standalone) + assertEquals(emptySet(), assembled.unreadable) + assertEquals(3, assembled.totalPasswordsScanned) + } + + private fun logins(range: IntRange) = range.map { login(id(it)) } + + private fun relation(type: RelationType, vararg ids: Int) = HealthFinding.Relation( + relatedItemIds = ids.mapTo(mutableSetOf(), ::id), + type = type, + ) + + private fun report( + items: List, + relations: List = emptyList(), + gaps: Map = emptyMap(), + ) = StoredHealthReport( + items = items, + relationalFindings = relations, + gaps = gaps, + breachCheckEnabled = true, + ) +} diff --git a/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/domain/report/HealthReportScannerTest.kt b/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/domain/report/HealthReportScannerTest.kt new file mode 100644 index 000000000..ed3aeb113 --- /dev/null +++ b/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/domain/report/HealthReportScannerTest.kt @@ -0,0 +1,486 @@ +package de.davis.keygo.feature.password_health.domain.report + +import de.davis.keygo.core.item.FakeItemRepository +import de.davis.keygo.core.item.domain.alias.ItemId +import de.davis.keygo.core.item.domain.model.ItemKeyEnvelope +import de.davis.keygo.core.item.domain.model.KeyInformation +import de.davis.keygo.core.item.domain.model.Login +import de.davis.keygo.core.item.domain.model.PasswordScore +import de.davis.keygo.core.security.crypto.FakeCryptographicScopeProvider +import de.davis.keygo.core.util.Result +import de.davis.keygo.feature.password_health.domain.checker.PasswordHealthChecker +import de.davis.keygo.feature.password_health.domain.model.BreachResult +import de.davis.keygo.feature.password_health.domain.model.CheckGap +import de.davis.keygo.feature.password_health.domain.model.CheckKind +import de.davis.keygo.feature.password_health.domain.model.CheckOutcome +import de.davis.keygo.feature.password_health.domain.model.GapReason +import de.davis.keygo.feature.password_health.domain.model.HealthFinding +import de.davis.keygo.feature.password_health.domain.model.HealthFingerprint +import de.davis.keygo.feature.password_health.domain.model.ItemIssue +import de.davis.keygo.feature.password_health.domain.model.PasswordCandidate +import de.davis.keygo.feature.password_health.domain.model.PasswordHealthReportError +import de.davis.keygo.feature.password_health.domain.model.RelationType +import de.davis.keygo.feature.password_health.domain.model.StoredHealthItem +import de.davis.keygo.feature.password_health.domain.model.StoredHealthReport +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertTrue +import kotlin.time.Instant + +class HealthReportScannerTest { + + private val itemRepository = FakeItemRepository() + private val scopeProvider = FakeCryptographicScopeProvider(itemRepository) + + @Test + fun checkersSeeTheDecryptedPasswords() = runTest { + val checker = RecordingChecker(CheckKind.Strength) + val logins = readable(0, 1) + + scan(logins, checker) + + assertEquals( + mapOf(id(0) to "password-${id(0)}", id(1) to "password-${id(1)}"), + checker.seen, + ) + } + + @Test + fun passwordsAreWipedOnceTheCheckersAreDone() = runTest { + val checker = RecordingChecker(CheckKind.Strength) + + scan(readable(0, 1), checker) + + assertTrue(checker.candidates.all { c -> c.password.all { it == '\u0000' } }) + } + + @Test + fun findingsLandOnTheItemsTheyAreAbout() = runTest { + val strength = RecordingChecker( + CheckKind.Strength, + CheckOutcome( + findings = listOf( + HealthFinding.Item(id(0), ItemIssue.Weak(PasswordScore.Weak)), + HealthFinding.Relation(setOf(id(0), id(1)), RelationType.Reused), + ), + ), + ) + val breachCheck = RecordingChecker( + CheckKind.Breach, + CheckOutcome(findings = listOf(HealthFinding.Item(id(1), ItemIssue.Breached(4)))), + ) + + val report = scan(readable(0, 1), strength, breachCheck).success() + + assertEquals( + listOf( + storedItem(id(0), score = PasswordScore.Weak, breach = breach(0, NOW)), + storedItem(id(1), breach = breach(4, NOW)), + ), + report.items, + ) + assertEquals( + listOf(HealthFinding.Relation(setOf(id(0), id(1)), RelationType.Reused)), + report.relationalFindings, + ) + } + + @Test + fun aSkippedCheckIsKeptAsAGapUnderItsKind() = runTest { + val gap = CheckGap(GapReason.Unreachable, setOf(id(0))) + val breachCheck = RecordingChecker(CheckKind.Breach, CheckOutcome(gap = gap)) + val strength = RecordingChecker(CheckKind.Strength) + + val report = scan(readable(0), breachCheck, strength).success() + + assertEquals(mapOf(CheckKind.Breach to gap), report.gaps) + } + + @Test + fun aPasswordThatWillNotDecryptIsReportedUnreadable() = runTest { + val checker = RecordingChecker(CheckKind.Strength) + val logins = readable(0) + login(id(1)) + + val report = scan(logins, checker).success() + + assertEquals(setOf(id(0)), checker.seen.keys) + assertEquals(listOf(false, true), report.items.map { it.unreadable }) + } + + @Test + fun anUnreachableLookupLeavesItsItemsWithoutABreachResult() = runTest { + val breachCheck = RecordingChecker( + CheckKind.Breach, + CheckOutcome(gap = CheckGap(GapReason.Unreachable, setOf(id(0)))), + ) + + val report = scan(readable(0, 1), breachCheck).success() + + assertEquals(listOf(null, breach(0, NOW)), report.items.map { it.breach }) + } + + @Test + fun anUnchangedVaultOnlyRepeatsTheBreachLookupsThatRanOut() = runTest { + val strength = RecordingChecker(CheckKind.Strength) + val breachCheck = RecordingChecker(CheckKind.Breach) + val relation = HealthFinding.Relation(setOf(id(0), id(1)), RelationType.Similar) + val previous = report( + storedItem(id(0), score = PasswordScore.Weak, breach = breach(1, NOW)), + storedItem(id(1), breach = breach(0, NOW - BreachResult.TTL)), + relations = listOf(relation), + ) + + val report = scan(readable(0, 1), strength, breachCheck, previous = previous).success() + + assertTrue(strength.candidates.isEmpty()) + assertEquals(setOf(id(1)), breachCheck.seen.keys) + assertEquals( + listOf( + storedItem(id(0), score = PasswordScore.Weak, breach = breach(1, NOW)), + storedItem(id(1), breach = breach(0, NOW)), + ), + report.items, + ) + assertEquals(listOf(relation), report.relationalFindings) + } + + @Test + fun aChangedPasswordRerunsTheLocalChecksButOnlyItsOwnBreachLookup() = runTest { + val strength = RecordingChecker(CheckKind.Strength) + val breachCheck = RecordingChecker(CheckKind.Breach) + val previous = report( + storedItem(id(0), score = PasswordScore.Weak, breach = breach(1, NOW)), + storedItem(id(1), breach = breach(0, NOW)).copy( + fingerprint = HealthFingerprint( + byteArrayOf(1) + ) + ), + relations = listOf(HealthFinding.Relation(setOf(id(0), id(1)), RelationType.Similar)), + ) + + val report = scan(readable(0, 1), strength, breachCheck, previous = previous).success() + + assertEquals(setOf(id(0), id(1)), strength.seen.keys) + assertEquals(setOf(id(1)), breachCheck.seen.keys) + assertEquals( + listOf( + storedItem(id(0), breach = breach(1, NOW)), + storedItem(id(1), breach = breach(0, NOW)), + ), + report.items, + ) + assertTrue(report.relationalFindings.isEmpty()) + } + + @Test + fun turningTheBreachCheckOffDropsTheKeptLookups() = runTest { + val breachCheck = RecordingChecker( + CheckKind.Breach, + CheckOutcome(gap = CheckGap(GapReason.Disabled, setOf(id(0)))), + ) + val previous = report(storedItem(id(0), breach = breach(3, NOW))) + + val report = scan( + readable(0), + breachCheck, + previous = previous, + breachCheckEnabled = false, + ).success() + + assertEquals(setOf(id(0)), breachCheck.seen.keys) + assertEquals(listOf(storedItem(id(0))), report.items) + } + + @Test + fun nothingToCheckIsNoPasswords() = runTest { + val result = scan( + listOf(login(id(0)).copy(passwordCredential = null)), + RecordingChecker(CheckKind.Strength), + ) + + assertEquals(Result.Failure(PasswordHealthReportError.NoPasswords), result) + } + + @Test + fun aVaultWhosePasswordsAllFailToDecryptIsUnreadable() = runTest { + val result = scan(listOf(login(id(0)), login(id(1))), RecordingChecker(CheckKind.Strength)) + + assertEquals(Result.Failure(PasswordHealthReportError.Unreadable), result) + } + + @Test + fun anEmptyVaultIsNoPasswords() = runTest { + val result = scan(emptyList(), RecordingChecker(CheckKind.Strength)) + + assertEquals(Result.Failure(PasswordHealthReportError.NoPasswords), result) + } + + @Test + fun aLoginWithoutAPasswordIsNeitherCheckedNorUnreadable() = runTest { + val checker = RecordingChecker(CheckKind.Strength) + val logins = readable(0) + login(id(1)).copy(passwordCredential = null) + + val report = scan(logins, checker).success() + + assertEquals(setOf(id(0)), checker.seen.keys) + assertEquals(listOf(false, false), report.items.map { it.unreadable }) + } + + @Test + fun aLoginWithoutAFingerprintIsLeftOutOfTheReport() = runTest { + val logins = readable(0, 1) + + val report = HealthReportScanner(scopeProvider, listOf(RecordingChecker(CheckKind.Strength))) + .scan( + logins = logins, + fingerprints = mapOf(id(0) to fingerprint(id(0))), + previous = null, + breachCheckEnabled = true, + now = NOW, + ).success() + + assertEquals(listOf(id(0)), report.items.map { it.id }) + } + + @Test + fun theReportRemembersWhetherTheBreachCheckWasOn() = runTest { + val on = scan(readable(0), RecordingChecker(CheckKind.Strength)).success() + val off = scan( + readable(0), + RecordingChecker(CheckKind.Strength), + breachCheckEnabled = false, + ).success() + + assertEquals(true, on.breachCheckEnabled) + assertEquals(false, off.breachCheckEnabled) + } + + @Test + fun withoutABreachCheckerNoPasswordGetsABreachResult() = runTest { + val report = scan(readable(0, 1), RecordingChecker(CheckKind.Strength)).success() + + assertEquals(listOf(null, null), report.items.map { it.breach }) + } + + @Test + fun everyCheckerSeesEveryPasswordOnAFreshScan() = runTest { + val checkers = listOf( + RecordingChecker(CheckKind.Strength), + RecordingChecker(CheckKind.Reuse), + RecordingChecker(CheckKind.Similarity), + RecordingChecker(CheckKind.Breach), + ) + + scan(readable(0, 1, 2), *checkers.toTypedArray()) + + checkers.forEach { assertEquals(setOf(id(0), id(1), id(2)), it.seen.keys, "${it.type}") } + } + + @Test + fun gapsOfSeveralChecksAreAllKept() = runTest { + val breachGap = CheckGap(GapReason.Unreachable, setOf(id(0))) + val similarityGap = CheckGap(GapReason.Failed, setOf(id(0), id(1))) + + val report = scan( + readable(0, 1), + RecordingChecker(CheckKind.Breach, CheckOutcome(gap = breachGap)), + RecordingChecker(CheckKind.Similarity, CheckOutcome(gap = similarityGap)), + ).success() + + assertEquals( + mapOf(CheckKind.Breach to breachGap, CheckKind.Similarity to similarityGap), + report.gaps, + ) + } + + @Test + fun aFullyCurrentReportIsRefreshedWithoutDecryptingAnything() = runTest { + val strength = RecordingChecker(CheckKind.Strength) + val breachCheck = RecordingChecker(CheckKind.Breach) + val previous = report( + storedItem(id(0), breach = breach(0, NOW)), + storedItem(id(1), breach = breach(2, NOW)), + ) + + val report = scan(readable(0, 1), strength, breachCheck, previous = previous).success() + + assertTrue(strength.candidates.isEmpty()) + assertTrue(breachCheck.seen.isEmpty()) + assertEquals(previous, report) + } + + @Test + fun aRefreshReplacesTheOldBreachGapAndKeepsTheRest() = runTest { + val disabledSimilarity = CheckGap(GapReason.Disabled, setOf(id(0))) + val newGap = CheckGap(GapReason.Failed, setOf(id(0))) + val previous = report(storedItem(id(0))).copy( + gaps = mapOf( + CheckKind.Breach to CheckGap(GapReason.Unreachable, setOf(id(0))), + CheckKind.Similarity to disabledSimilarity, + ), + ) + + val report = scan( + readable(0), + RecordingChecker(CheckKind.Breach, CheckOutcome(gap = newGap)), + previous = previous, + ).success() + + assertEquals( + mapOf(CheckKind.Breach to newGap, CheckKind.Similarity to disabledSimilarity), + report.gaps, + ) + assertEquals(listOf(null), report.items.map { it.breach }) + } + + @Test + fun aRefreshThatReachesTheServiceClearsTheOldBreachGap() = runTest { + val previous = report(storedItem(id(0))).copy( + gaps = mapOf(CheckKind.Breach to CheckGap(GapReason.Unreachable, setOf(id(0)))), + ) + + val report = scan( + readable(0), + RecordingChecker(CheckKind.Breach), + previous = previous, + ).success() + + assertEquals(emptyMap(), report.gaps) + assertEquals(listOf(breach(0, NOW)), report.items.map { it.breach }) + } + + @Test + fun aPasswordThatStopsDecryptingDuringARefreshIsMarkedUnreadable() = runTest { + val previous = report( + storedItem(id(0), breach = breach(0, NOW)), + storedItem(id(1), breach = breach(0, NOW - BreachResult.TTL)), + ) + val logins = readable(0) + login(id(1)) + + val report = scan( + logins, + RecordingChecker(CheckKind.Breach), + previous = previous, + ).success() + + assertEquals(listOf(false, true), report.items.map { it.unreadable }) + assertEquals(listOf(breach(0, NOW), null), report.items.map { it.breach }) + } + + @Test + fun aPreviouslyUnreadablePasswordForcesAFullScan() = runTest { + val strength = RecordingChecker(CheckKind.Strength) + val previous = report(storedItem(id(0), unreadable = true, breach = breach(0, NOW))) + + val report = scan(readable(0), strength, previous = previous).success() + + assertEquals(setOf(id(0)), strength.seen.keys) + assertEquals(listOf(false), report.items.map { it.unreadable }) + } + + @Test + fun aFailedLocalCheckForcesAFullScan() = runTest { + val similarity = RecordingChecker(CheckKind.Similarity) + val previous = report(storedItem(id(0), breach = breach(0, NOW))).copy( + gaps = mapOf(CheckKind.Similarity to CheckGap(GapReason.Failed, setOf(id(0)))), + ) + + val report = scan(readable(0), similarity, previous = previous).success() + + assertEquals(setOf(id(0)), similarity.seen.keys) + assertEquals(emptyMap(), report.gaps) + } + + @Test + fun keptBreachLookupsAreNotRepeatedOnAFullScan() = runTest { + val breachCheck = RecordingChecker( + CheckKind.Breach, + CheckOutcome(findings = listOf(HealthFinding.Item(id(0), ItemIssue.Breached(99)))), + ) + val previous = report(storedItem(id(0), breach = breach(5, NOW))) + + val report = scan( + readable(0, 1), + breachCheck, + previous = previous, + ).success() + + assertEquals(setOf(id(1)), breachCheck.seen.keys) + assertEquals(listOf(breach(5, NOW), breach(0, NOW)), report.items.map { it.breach }) + } + + @Test + fun passwordsAreWipedEvenWhenACheckerFails() = runTest { + val failing = object : PasswordHealthChecker { + override val type = CheckKind.Similarity + var seen: List = emptyList() + + override suspend fun check(candidates: List): CheckOutcome { + seen = candidates + error("boom") + } + } + + runCatching { scan(readable(0), failing) } + + assertTrue(failing.seen.single().password.all { it == '\u0000' }) + } + + private suspend fun scan( + logins: List, + vararg checkers: PasswordHealthChecker, + previous: StoredHealthReport? = null, + breachCheckEnabled: Boolean = true, + ) = HealthReportScanner(scopeProvider, checkers.toList()).scan( + logins = logins, + fingerprints = logins.associate { it.id to fingerprint(it.id) }, + previous = previous, + breachCheckEnabled = breachCheckEnabled, + now = NOW, + ) + + private fun report( + vararg items: StoredHealthItem, + relations: List = emptyList(), + ) = StoredHealthReport( + items = items.toList(), + relationalFindings = relations, + breachCheckEnabled = true, + ) + + /** Logins whose item key the fake provider can find, so their passwords decrypt. */ + private fun readable(vararg ns: Int) = ns.map { n -> + itemRepository.seedEnvelope( + ItemKeyEnvelope( + vaultId = VAULT_ID, + itemId = id(n), + itemKeyInformation = KeyInformation(byteArrayOf(), byteArrayOf()), + vaultKeyInformation = KeyInformation(byteArrayOf(), byteArrayOf()), + ), + ) + login(id(n)) + } + + private fun Result.success(): S = assertIs>(this).success + + private class RecordingChecker( + override val type: CheckKind, + private val outcome: CheckOutcome = CheckOutcome(), + ) : PasswordHealthChecker { + + var candidates: List = emptyList() + var seen: Map = emptyMap() + + override suspend fun check(candidates: List): CheckOutcome { + this.candidates = candidates + seen = candidates.associate { it.id to it.password.concatToString() } + return outcome + } + } + + private companion object { + val NOW = Instant.fromEpochSeconds(1_000_000) + } +} diff --git a/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/domain/report/ReportFixtures.kt b/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/domain/report/ReportFixtures.kt new file mode 100644 index 000000000..48db13a3a --- /dev/null +++ b/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/domain/report/ReportFixtures.kt @@ -0,0 +1,68 @@ +package de.davis.keygo.feature.password_health.domain.report + +import de.davis.keygo.core.item.domain.alias.ItemId +import de.davis.keygo.core.item.domain.model.EncryptedPayload +import de.davis.keygo.core.item.domain.model.KeyInformation +import de.davis.keygo.core.item.domain.model.Login +import de.davis.keygo.core.item.domain.model.PasswordCredential +import de.davis.keygo.core.item.domain.model.PasswordScore +import de.davis.keygo.core.item.domain.model.PasswordSecret +import de.davis.keygo.core.item.domain.model.Timestamp +import de.davis.keygo.core.security.crypto.FakeCryptographicScopeProvider +import de.davis.keygo.feature.password_health.domain.model.BreachResult +import de.davis.keygo.feature.password_health.domain.model.HealthFingerprint +import de.davis.keygo.feature.password_health.domain.model.StoredHealthItem +import java.util.UUID +import kotlin.time.Instant + +internal val VAULT_ID: UUID = UUID(1L, 0L) + +internal fun id(n: Int): ItemId = UUID(0L, n.toLong()) + +/** A login whose password [FakeCryptographicScopeProvider] decrypts back to [password]. */ +internal fun login( + id: ItemId, + password: String = "password-$id", + score: PasswordScore = PasswordScore.Strong, +) = Login( + id = id, + username = "user-$id", + domainInfos = emptySet(), + passwordCredential = PasswordCredential( + secret = PasswordSecret( + EncryptedPayload( + ciphertext = FakeCryptographicScopeProvider.transform( + password.encodeToByteArray(), + ), + iv = FakeCryptographicScopeProvider.IV, + ), + ), + score = score, + ), + totp = null, + passkeys = emptySet(), + vaultId = VAULT_ID, + name = "login-$id", + keyInformation = KeyInformation(byteArrayOf(), byteArrayOf()), + timestamp = Timestamp(), + note = null, + pinned = false, +) + +internal fun fingerprint(id: ItemId) = HealthFingerprint(id.toString().encodeToByteArray()) + +internal fun storedItem( + id: ItemId, + score: PasswordScore? = null, + breach: BreachResult? = null, + unreadable: Boolean = false, +) = StoredHealthItem( + id = id, + fingerprint = fingerprint(id), + score = score, + breach = breach, + unreadable = unreadable, +) + +internal fun breach(occurrences: Int, checkedAt: Instant = Instant.fromEpochSeconds(0)) = + BreachResult(occurrences = occurrences, checkedAt = checkedAt) diff --git a/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/domain/report/UnionFindTest.kt b/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/domain/report/UnionFindTest.kt new file mode 100644 index 000000000..0f3876c06 --- /dev/null +++ b/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/domain/report/UnionFindTest.kt @@ -0,0 +1,76 @@ +package de.davis.keygo.feature.password_health.domain.report + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotEquals +import kotlin.test.assertTrue + +class UnionFindTest { + + private val uf = UnionFind() + + @Test + fun anUnseenElementIsNotContained() { + assertFalse(uf.contains(1)) + } + + @Test + fun findingAnElementAddsItAsItsOwnRoot() { + assertEquals(1, uf.find(1)) + assertTrue(uf.contains(1)) + } + + @Test + fun unitedElementsShareARoot() { + uf.union(1, 2) + + assertEquals(uf.find(1), uf.find(2)) + assertTrue(uf.contains(1)) + assertTrue(uf.contains(2)) + } + + @Test + fun unionIsTransitive() { + uf.union(1, 2) + uf.union(3, 4) + uf.union(2, 3) + + val root = uf.find(1) + assertEquals(listOf(root, root, root), listOf(2, 3, 4).map(uf::find)) + } + + @Test + fun separateSetsKeepSeparateRoots() { + uf.union(1, 2) + uf.union(3, 4) + + assertNotEquals(uf.find(1), uf.find(3)) + } + + @Test + fun unitingAnElementWithItselfChangesNothing() { + uf.union(1, 1) + + assertEquals(1, uf.find(1)) + } + + @Test + fun unitingTheSameSetTwiceChangesNothing() { + uf.union(1, 2) + val root = uf.find(1) + + uf.union(2, 1) + + assertEquals(root, uf.find(1)) + assertEquals(root, uf.find(2)) + } + + @Test + fun aLongChainStillResolvesToOneRoot() { + (0 until 1_000).zipWithNext().forEach { (a, b) -> uf.union(a, b) } + + val root = uf.find(0) + assertTrue((0 until 1_000).all { uf.find(it) == root }) + } +} diff --git a/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/domain/usecase/PasswordHealthReportUseCaseTest.kt b/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/domain/usecase/PasswordHealthReportUseCaseTest.kt new file mode 100644 index 000000000..bd68e542a --- /dev/null +++ b/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/domain/usecase/PasswordHealthReportUseCaseTest.kt @@ -0,0 +1,510 @@ +package de.davis.keygo.feature.password_health.domain.usecase + +import de.davis.keygo.core.item.FakeItemRepository +import de.davis.keygo.core.item.FakeLoginRepository +import de.davis.keygo.core.item.domain.alias.ItemId +import de.davis.keygo.core.item.domain.model.ItemKeyEnvelope +import de.davis.keygo.core.item.domain.model.KeyInformation +import de.davis.keygo.core.item.domain.model.Login +import de.davis.keygo.core.item.domain.model.PasswordScore +import de.davis.keygo.core.security.FakeSession +import de.davis.keygo.core.security.crypto.FakeCryptographicScopeProvider +import de.davis.keygo.core.util.Result +import de.davis.keygo.core.util.assertFailure +import de.davis.keygo.core.util.assertSuccess +import de.davis.keygo.feature.password_health.FakeBreachedRepository +import de.davis.keygo.feature.password_health.FakeConnectivityRepository +import de.davis.keygo.feature.password_health.FakeHealthCheckNotifierScheduler +import de.davis.keygo.feature.password_health.FakeHealthNotificationStateRepository +import de.davis.keygo.feature.password_health.FakeHealthSettingsRepository +import de.davis.keygo.feature.password_health.data.LoginFingerprinterImpl +import de.davis.keygo.feature.password_health.domain.PasswordHealthAttention +import de.davis.keygo.feature.password_health.domain.checker.BreachedPasswordCheck +import de.davis.keygo.feature.password_health.domain.checker.PasswordHealthChecker +import de.davis.keygo.feature.password_health.domain.checker.ReusePasswordCheck +import de.davis.keygo.feature.password_health.domain.checker.WeakPasswordChecker +import de.davis.keygo.feature.password_health.domain.model.BreachResult +import de.davis.keygo.feature.password_health.domain.model.BreachedError +import de.davis.keygo.feature.password_health.domain.model.CheckGap +import de.davis.keygo.feature.password_health.domain.model.CheckKind +import de.davis.keygo.feature.password_health.domain.model.CheckOutcome +import de.davis.keygo.feature.password_health.domain.model.GapReason +import de.davis.keygo.feature.password_health.domain.model.HealthReportStoreError +import de.davis.keygo.feature.password_health.domain.model.HealthSnapshot +import de.davis.keygo.feature.password_health.domain.model.ItemIssue +import de.davis.keygo.feature.password_health.domain.model.PasswordCandidate +import de.davis.keygo.feature.password_health.domain.model.PasswordHealthReportError +import de.davis.keygo.feature.password_health.domain.model.RelationType +import de.davis.keygo.feature.password_health.domain.model.StoredHealthItem +import de.davis.keygo.feature.password_health.domain.model.StoredHealthReport +import de.davis.keygo.feature.password_health.domain.model.VaultFingerprint +import de.davis.keygo.feature.password_health.domain.report.HealthReportAssembler +import de.davis.keygo.feature.password_health.domain.report.HealthReportScanner +import de.davis.keygo.feature.password_health.domain.report.VAULT_ID +import de.davis.keygo.feature.password_health.domain.report.id +import de.davis.keygo.feature.password_health.domain.report.login +import de.davis.keygo.feature.password_health.domain.repository.HealthReportStoreRepository +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import java.security.MessageDigest +import java.util.Collections +import kotlin.coroutines.EmptyCoroutineContext +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue +import kotlin.time.Instant + +// Robolectric only so a failed store write can reach android.util.Log. +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34]) +class PasswordHealthReportUseCaseTest { + + private val loginRepository = FakeLoginRepository() + private val itemRepository = FakeItemRepository(loginRepository) + private val scopeProvider = FakeCryptographicScopeProvider(itemRepository) + private val fingerprinter = LoginFingerprinterImpl() + private val store = FakeHealthReportStore() + private val breachState = FakeHealthSettingsRepository(breachesEnabled = true) + private val breached = FakeBreachedRepository() + private val notificationState = FakeHealthNotificationStateRepository() + private val scheduler = FakeHealthCheckNotifierScheduler() + private val attention = PasswordHealthAttention( + session = FakeSession(startUnlocked = true), + appScope = CoroutineScope(Dispatchers.Unconfined), + ) + + private val strength = Counting(WeakPasswordChecker()) + private val reuse = Counting(ReusePasswordCheck()) + private val breach = Counting( + BreachedPasswordCheck(breached, breachState, FakeConnectivityRepository()), + ) + + private val useCase = PasswordHealthReportUseCase( + loginRepository = loginRepository, + loginFingerprinter = fingerprinter, + healthReportStoreRepository = store, + healthSettingsRepository = breachState, + scanner = HealthReportScanner(scopeProvider, listOf(strength, reuse, breach)), + assembler = HealthReportAssembler(), + attention = attention, + healthNotificationStateRepository = notificationState, + healthCheckNotifierScheduler = scheduler, + ) + + @Test + fun anEmptyVaultIsNoPasswords() = runTest { + assertEquals(PasswordHealthReportError.NoPasswords, run().assertFailure()) + } + + @Test + fun aVaultWithoutPasswordsIsNoPasswords() = runTest { + loginRepository.seed(login(id(0)).copy(passwordCredential = null)) + + assertEquals(PasswordHealthReportError.NoPasswords, run().assertFailure()) + assertTrue(strength.calls.isEmpty()) + } + + @Test + fun loginsWithoutAPasswordAreLeftOut() = runTest { + seed(0) + loginRepository.seed(login(id(1)).copy(passwordCredential = null)) + + val report = run().assertSuccess() + + assertEquals(1, report.totalPasswordsScanned) + assertEquals(listOf(setOf(id(0))), strength.calls) + } + + @Test + fun aFirstRunScansEveryPasswordAndStoresTheResult() = runTest { + seed(0, 1, score = PasswordScore.Weak) + + val report = run().assertSuccess() + + assertEquals(2, report.totalPasswordsScanned) + assertEquals(setOf(id(0), id(1)), report.standalone.mapTo(mutableSetOf()) { it.itemId }) + assertEquals( + listOf(ItemIssue.Weak(PasswordScore.Weak)), + report.standalone.first().issues, + ) + assertEquals(2, assertNotNull(store.stored).items.size) + assertEquals(1, store.writes) + } + + @Test + fun reusedPasswordsFormAGroup() = runTest { + seed(0, 1, password = "same") + + val group = run().assertSuccess().groups.single() + + assertEquals(RelationType.Reused, group.dominantRelation) + assertEquals(setOf(id(0), id(1)), group.members.mapTo(mutableSetOf()) { it.itemId }) + } + + @Test + fun onlyTheItemsThatNeedAttentionAreCounted() = runTest { + seed(0, 1, score = PasswordScore.Weak) + seed(2) + + run().assertSuccess() + + assertEquals(2, attention.needsAttention.value) + } + + @Test + fun everyMemberOfAGroupIsCountedNotTheGroup() = runTest { + seed(0, 1, password = "same") + seed(2, score = PasswordScore.Weak) + + run().assertSuccess() + + assertEquals(3, attention.needsAttention.value) + } + + @Test + fun aReportServedFromTheStoreIsCountedToo() = runTest { + seed(0, 1, score = PasswordScore.Weak) + run().assertSuccess() + attention.clear() + + run().assertSuccess() + + assertEquals(1, store.writes) + assertEquals(2, attention.needsAttention.value) + } + + @Test + fun aVaultThatBecameEmptyClearsTheCount() = runTest { + attention.update(3) + + assertEquals(PasswordHealthReportError.NoPasswords, run().assertFailure()) + + assertEquals(0, attention.needsAttention.value) + } + + @Test + fun breachedPasswordsAreReported() = runTest { + seed(0, password = "password") + breached.breaches = mapOf("5BAA61E4C9B93F3F0682250B6CF8331B7EE68FD8" to 3) + + val item = run().assertSuccess().standalone.single() + + assertEquals(listOf(ItemIssue.Breached(3)), item.issues) + } + + @Test + fun aSnapshotIsRecordedForTheReminderWhenItIsOn() = runTest { + breachState.setNotificationEnabled(true) + seed(0, password = "password") + seed(1, score = PasswordScore.Weak) + breached.breaches = mapOf("5BAA61E4C9B93F3F0682250B6CF8331B7EE68FD8" to 3) + + run().assertSuccess() + + val snapshot = assertNotNull(notificationState.state.snapshot) + assertEquals(2, snapshot.attentionCount) + assertEquals(1, snapshot.breachedCount) + assertEquals(currentVault(), snapshot.vault) + assertTrue(scheduler.scheduled) + } + + @Test + fun aCachedReportAlsoRecordsASnapshot() = runTest { + seed(0, score = PasswordScore.Weak) + run().assertSuccess() + breachState.setNotificationEnabled(true) + + run().assertSuccess() + + assertEquals(1, assertNotNull(notificationState.state.snapshot).attentionCount) + assertEquals(1, store.writes) + } + + @Test + fun noSnapshotIsRecordedWhileTheReminderIsOff() = runTest { + seed(0, score = PasswordScore.Weak) + + run().assertSuccess() + + assertNull(notificationState.state.snapshot) + assertFalse(scheduler.scheduled) + } + + @Test + fun anEmptyVaultClearsTheSnapshot() = runTest { + breachState.setNotificationEnabled(true) + notificationState.setSnapshot(HealthSnapshot(3, 0, VaultFingerprint(byteArrayOf(1)))) + + run().assertFailure() + + assertNull(notificationState.state.snapshot) + } + + @Test + fun aFreshStoredReportIsServedWithoutScanning() = runTest { + seed(0, 1, score = PasswordScore.Weak) + val first = run().assertSuccess() + + val second = run().assertSuccess() + + assertEquals(first, second) + assertEquals(1, strength.calls.size) + assertEquals(1, breach.calls.size) + assertEquals(1, store.writes) + } + + @Test + fun aForcedRunScansEverythingAgain() = runTest { + seed(0, 1) + run().assertSuccess() + + run(force = true).assertSuccess() + + assertEquals(2, strength.calls.size) + assertEquals(listOf(setOf(id(0), id(1)), setOf(id(0), id(1))), breach.calls) + assertEquals(2, store.writes) + } + + @Test + fun anEditedPasswordTriggersAFreshScan() = runTest { + seed(0, 1) + run().assertSuccess() + + seed(1, password = "changed", score = PasswordScore.Weak) + val report = run().assertSuccess() + + assertEquals(2, strength.calls.size) + assertEquals(listOf(id(1)), report.standalone.map { it.itemId }) + assertEquals(setOf(id(1)), breach.calls.last()) + } + + @Test + fun aNewLoginTriggersAFreshScan() = runTest { + seed(0) + run().assertSuccess() + + seed(1) + val report = run().assertSuccess() + + assertEquals(2, report.totalPasswordsScanned) + assertEquals(setOf(id(1)), breach.calls.last()) + } + + @Test + fun aDeletedLoginDropsOutOfTheReport() = runTest { + seed(0, 1, score = PasswordScore.Weak) + run().assertSuccess() + + itemRepository.deleteItems(setOf(id(1))) + val report = run().assertSuccess() + + assertEquals(1, report.totalPasswordsScanned) + assertEquals(listOf(id(0)), report.standalone.map { it.itemId }) + } + + @Test + fun anExpiredBreachLookupOnlyRepeatsTheBreachCheck() = runTest { + seed(0, 1) + store.stored = StoredHealthReport( + items = listOf(0, 1).map { n -> + StoredHealthItem( + id = id(n), + fingerprint = assertNotNull(fingerprinter.fingerprint(loginOf(n))), + score = null, + breach = BreachResult(0, Instant.fromEpochSeconds(0)), + unreadable = false, + ) + }, + relationalFindings = emptyList(), + breachCheckEnabled = true, + ) + + run().assertSuccess() + + assertTrue(strength.calls.isEmpty()) + assertEquals(listOf(setOf(id(0), id(1))), breach.calls) + assertEquals(1, store.writes) + } + + @Test + fun turningTheBreachCheckOffRescansWithoutLookups() = runTest { + seed(0) + run().assertSuccess() + + breachState.setBreachEnabled(false) + val report = run().assertSuccess() + + assertEquals(1, breached.calls.size) + assertEquals(false, assertNotNull(store.stored).breachCheckEnabled) + assertEquals(GapReason.Disabled, report.gaps[CheckKind.Breach]?.reason) + } + + @Test + fun anUnreachableBreachServiceIsReportedAsAGap() = runTest { + seed(0) + breached.failures[prefixOf(loginOf(0))] = BreachedError.Unreachable + + val report = run().assertSuccess() + + assertEquals(CheckGap(GapReason.Unreachable, setOf(id(0))), report.gaps[CheckKind.Breach]) + } + + @Test + fun anUnreachableBreachServiceIsAskedAgainNextTime() = runTest { + seed(0) + breached.failures[prefixOf(loginOf(0))] = BreachedError.Unreachable + run().assertSuccess() + + breached.failures.clear() + val report = run().assertSuccess() + + assertEquals(emptyMap(), report.gaps) + assertEquals(1, strength.calls.size) + assertEquals(2, breach.calls.size) + } + + @Test + fun aReportThatCannotBeStoredIsStillReturned() = runTest { + seed(0, score = PasswordScore.Weak) + store.storeFailure = HealthReportStoreError.CryptoFailed + + val report = run().assertSuccess() + + assertEquals(listOf(id(0)), report.standalone.map { it.itemId }) + assertNull(store.stored) + } + + @Test + fun aStoredReportThatCannotBeReadIsScannedAgain() = runTest { + seed(0) + run().assertSuccess() + store.loadFailure = HealthReportStoreError.CorruptedReport + + run().assertSuccess() + + assertEquals(2, strength.calls.size) + } + + @Test + fun anUnreadablePasswordIsReportedAndForcesTheNextScan() = runTest { + seed(0) + loginRepository.seed(login(id(1))) + + val report = run().assertSuccess() + assertEquals(setOf(id(1)), report.unreadable) + + run().assertSuccess() + assertEquals(2, strength.calls.size) + } + + @Test + fun aScanThatFailsLeavesTheCountAlone() = runTest { + attention.update(3) + loginRepository.seed(login(id(0))) + + assertEquals(PasswordHealthReportError.Unreadable, run().assertFailure()) + + assertEquals(3, attention.needsAttention.value) + } + + @Test + fun callersThatOverlapShareOneScan() = runTest { + seed(0) + val gate = CompletableDeferred() + strength.gate = gate + + val first = async { run() } + val second = async { run() } + runCurrent() + gate.complete(Unit) + + first.await().assertSuccess() + second.await().assertSuccess() + assertEquals(1, strength.calls.size) + } + + private suspend fun run(force: Boolean = false) = useCase(force, EmptyCoroutineContext) + + private suspend fun currentVault() = VaultFingerprint.of( + loginRepository.observeLogins().first().mapNotNull { fingerprinter.fingerprint(it) }, + ) + + /** Seeds logins whose passwords the fake provider can decrypt. */ + private fun seed( + vararg ns: Int, + password: String? = null, + score: PasswordScore = PasswordScore.Strong, + ) = ns.forEach { n -> + itemRepository.seedEnvelope( + ItemKeyEnvelope( + vaultId = VAULT_ID, + itemId = id(n), + itemKeyInformation = KeyInformation(byteArrayOf(), byteArrayOf()), + vaultKeyInformation = KeyInformation(byteArrayOf(), byteArrayOf()), + ), + ) + loginRepository.seed( + if (password == null) login(id(n), score = score) + else login(id(n), password = password, score = score), + ) + } + + private suspend fun loginOf(n: Int): Login = assertNotNull(loginRepository.getLoginById(id(n))) + + /** The range the breach check asks about for [login]'s password. */ + private fun prefixOf(login: Login): String { + val password = FakeCryptographicScopeProvider.transform( + login.passwordCredential!!.secret.payload.ciphertext, + ) + return MessageDigest.getInstance("SHA-1").digest(password) + .joinToString("") { "%02X".format(it) } + .take(5) + } + + private class Counting(private val delegate: PasswordHealthChecker) : PasswordHealthChecker { + + override val type = delegate.type + + val calls: MutableList> = Collections.synchronizedList(mutableListOf()) + + var gate: CompletableDeferred? = null + + override suspend fun check(candidates: List): CheckOutcome { + calls += candidates.mapTo(mutableSetOf()) { it.id } + gate?.await() + return delegate.check(candidates) + } + } + + private class FakeHealthReportStore : HealthReportStoreRepository { + + var stored: StoredHealthReport? = null + var writes = 0 + var storeFailure: HealthReportStoreError? = null + var loadFailure: HealthReportStoreError? = null + + override suspend fun storeReport(report: StoredHealthReport): Result { + storeFailure?.let { return Result.Failure(it) } + stored = report + writes++ + return Result.Success(Unit) + } + + override suspend fun load(): Result { + loadFailure?.let { return Result.Failure(it) } + return stored?.let { Result.Success(it) } + ?: Result.Failure(HealthReportStoreError.NoReportStored) + } + } +} diff --git a/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/domain/usecase/SendHealthReminderUseCaseTest.kt b/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/domain/usecase/SendHealthReminderUseCaseTest.kt new file mode 100644 index 000000000..c8db94d95 --- /dev/null +++ b/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/domain/usecase/SendHealthReminderUseCaseTest.kt @@ -0,0 +1,193 @@ +package de.davis.keygo.feature.password_health.domain.usecase + +import de.davis.keygo.core.item.FakeLoginRepository +import de.davis.keygo.feature.password_health.FakeHealthNotificationStateRepository +import de.davis.keygo.feature.password_health.FakeHealthSettingsRepository +import de.davis.keygo.feature.password_health.FakeNotifier +import de.davis.keygo.feature.password_health.data.LoginFingerprinterImpl +import de.davis.keygo.feature.password_health.domain.model.HealthNotificationState +import de.davis.keygo.feature.password_health.domain.model.HealthReminder +import de.davis.keygo.feature.password_health.domain.model.HealthSnapshot +import de.davis.keygo.feature.password_health.domain.model.KeyGoNotification +import de.davis.keygo.feature.password_health.domain.model.VaultFingerprint +import de.davis.keygo.feature.password_health.domain.report.id +import de.davis.keygo.feature.password_health.domain.report.login +import de.davis.keygo.feature.password_health.domain.usecase.SendHealthReminderUseCase.Companion.MAX_REPEATS +import de.davis.keygo.feature.password_health.domain.usecase.SendHealthReminderUseCase.Companion.REMIND_AFTER +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue +import kotlin.time.Duration.Companion.days +import kotlin.time.Instant + +class SendHealthReminderUseCaseTest { + + private val loginRepository = FakeLoginRepository() + private val fingerprinter = LoginFingerprinterImpl() + private val settings = FakeHealthSettingsRepository(notificationsEnabled = true) + private val state = FakeHealthNotificationStateRepository() + private val notifier = FakeNotifier() + + private val useCase = SendHealthReminderUseCase( + loginRepository = loginRepository, + loginFingerprinter = fingerprinter, + healthSettingsRepository = settings, + healthNotificationStateRepository = state, + notifier = notifier, + ) + + @Test + fun theFirstSnapshotWithIssuesIsAnnounced() = runTest { + snapshot(attention = 3) + + useCase(NOW) + + assertEquals(listOf(needsAttention(3)), notifier.sent) + assertEquals(reminder(attention = 3, at = NOW), state.state.reminder) + } + + @Test + fun theSameIssuesAreNotRepeatedBeforeTheReminderIsDue() = runTest { + snapshot(attention = 3, reminder = reminder(attention = 3, at = NOW)) + + useCase(NOW + REMIND_AFTER - 1.days) + + assertTrue(notifier.sent.isEmpty()) + } + + @Test + fun theSameIssuesAreRemindedOnceTheReminderIsDue() = runTest { + snapshot(attention = 3, reminder = reminder(attention = 3, at = NOW)) + val later = NOW + REMIND_AFTER + + useCase(later) + + assertEquals(listOf(needsAttention(3)), notifier.sent) + assertEquals(reminder(attention = 3, at = later, repeats = 1), state.state.reminder) + } + + @Test + fun remindersStopAfterTheLimit() = runTest { + snapshot(attention = 3, reminder = reminder(attention = 3, at = NOW, repeats = MAX_REPEATS)) + + useCase(NOW + REMIND_AFTER * 3) + + assertTrue(notifier.sent.isEmpty()) + } + + @Test + fun moreIssuesAreAnnouncedRightAwayAndRestartTheReminders() = runTest { + snapshot(attention = 4, reminder = reminder(attention = 3, at = NOW, repeats = MAX_REPEATS)) + val later = NOW + 1.days + + useCase(later) + + assertEquals(listOf(needsAttention(4)), notifier.sent) + assertEquals(reminder(attention = 4, at = later), state.state.reminder) + } + + @Test + fun aNewBreachIsAnnouncedEvenWhenTheTotalStaysTheSame() = runTest { + snapshot(attention = 3, breached = 1, reminder = reminder(attention = 3, at = NOW)) + + useCase(NOW + 1.days) + + assertEquals(listOf(needsAttention(3)), notifier.sent) + } + + @Test + fun fewerIssuesAreRememberedQuietly() = runTest { + snapshot(attention = 2, reminder = reminder(attention = 3, at = NOW, repeats = 1)) + + useCase(NOW + 1.days) + + assertTrue(notifier.sent.isEmpty()) + assertEquals(reminder(attention = 2, at = NOW, repeats = 1), state.state.reminder) + } + + @Test + fun aFixedVaultWithdrawsTheNotification() = runTest { + snapshot(attention = 0, reminder = reminder(attention = 3, at = NOW)) + + useCase(NOW + 1.days) + + assertTrue(notifier.sent.isEmpty()) + assertEquals(listOf(KeyGoNotification.Kind.NeedsAttention), notifier.cancelled) + assertNull(state.state.reminder) + } + + @Test + fun aSnapshotOfAnEditedVaultIsIgnored() = runTest { + snapshot(attention = 3) + loginRepository.seed(login(id(9))) + + useCase(NOW) + + assertTrue(notifier.sent.isEmpty()) + assertNull(state.state.reminder) + } + + @Test + fun withoutASnapshotTheNotificationIsWithdrawn() = runTest { + state.state = HealthNotificationState(reminder = reminder(attention = 3, at = NOW)) + + useCase(NOW + 1.days) + + assertTrue(notifier.sent.isEmpty()) + assertEquals(listOf(KeyGoNotification.Kind.NeedsAttention), notifier.cancelled) + assertNull(state.state.reminder) + } + + @Test + fun nothingHappensWhenRemindersAreOff() = runTest { + snapshot(attention = 3) + settings.setNotificationEnabled(false) + + useCase(NOW) + + assertTrue(notifier.sent.isEmpty()) + } + + @Test + fun aBlockedNotificationIsNotCountedAsSent() = runTest { + snapshot(attention = 3) + notifier.canNotify = false + + useCase(NOW) + + assertTrue(notifier.sent.isEmpty()) + assertNull(state.state.reminder) + } + + private suspend fun snapshot( + attention: Int, + breached: Int = 0, + reminder: HealthReminder? = null, + ) { + loginRepository.seed(login(id(0)), login(id(1))) + val vault = VaultFingerprint.of( + loginRepository.observeLogins().first().mapNotNull { fingerprinter.fingerprint(it) }, + ) + state.state = HealthNotificationState( + snapshot = HealthSnapshot(attention, breached, vault), + reminder = reminder, + ) + } + + private fun reminder(attention: Int, at: Instant, repeats: Int = 0) = HealthReminder( + attentionCount = attention, + breachedCount = 0, + sentAt = at, + repeats = repeats, + ) + + private fun needsAttention(count: Int): KeyGoNotification = + KeyGoNotification.NeedsAttention(count) + + private companion object { + val NOW = Instant.fromEpochSeconds(1_800_000_000) + } +} diff --git a/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/domain/usecase/SetHealthNotificationsUseCaseTest.kt b/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/domain/usecase/SetHealthNotificationsUseCaseTest.kt new file mode 100644 index 000000000..d07ef377a --- /dev/null +++ b/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/domain/usecase/SetHealthNotificationsUseCaseTest.kt @@ -0,0 +1,48 @@ +package de.davis.keygo.feature.password_health.domain.usecase + +import de.davis.keygo.feature.password_health.FakeHealthCheckNotifierScheduler +import de.davis.keygo.feature.password_health.FakeHealthNotificationStateRepository +import de.davis.keygo.feature.password_health.FakeHealthSettingsRepository +import de.davis.keygo.feature.password_health.FakeNotifier +import de.davis.keygo.feature.password_health.domain.model.HealthNotificationState +import de.davis.keygo.feature.password_health.domain.model.HealthSnapshot +import de.davis.keygo.feature.password_health.domain.model.KeyGoNotification +import de.davis.keygo.feature.password_health.domain.model.VaultFingerprint +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class SetHealthNotificationsUseCaseTest { + + private val settings = FakeHealthSettingsRepository(notificationsEnabled = false) + private val state = FakeHealthNotificationStateRepository( + HealthNotificationState(snapshot = HealthSnapshot(3, 0, VaultFingerprint(byteArrayOf(1)))), + ) + private val scheduler = FakeHealthCheckNotifierScheduler() + private val notifier = FakeNotifier() + + private val useCase = SetHealthNotificationsUseCase(settings, state, scheduler, notifier) + + @Test + fun turningOnSchedulesTheReminder() = runTest { + useCase(true) + + assertTrue(settings.state.value.notificationsEnabled) + assertTrue(scheduler.scheduled) + } + + @Test + fun turningOffCancelsAndForgetsEverything() = runTest { + useCase(true) + + useCase(false) + + assertFalse(settings.state.value.notificationsEnabled) + assertFalse(scheduler.scheduled) + assertEquals(HealthNotificationState(), state.state) + assertEquals(listOf(KeyGoNotification.Kind.NeedsAttention), notifier.cancelled) + } + +} diff --git a/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/presentation/PasswordHealthViewModelTest.kt b/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/presentation/PasswordHealthViewModelTest.kt new file mode 100644 index 000000000..fdbecf3ab --- /dev/null +++ b/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/presentation/PasswordHealthViewModelTest.kt @@ -0,0 +1,567 @@ +package de.davis.keygo.feature.password_health.presentation + +import de.davis.keygo.core.item.FakeCreditCardRepository +import de.davis.keygo.core.item.FakeItemRepository +import de.davis.keygo.core.item.FakeLoginRepository +import de.davis.keygo.core.item.FakePasswordStrengthEstimator +import de.davis.keygo.core.item.FakeVaultRepository +import de.davis.keygo.core.item.domain.model.ItemKeyEnvelope +import de.davis.keygo.core.item.domain.model.KeyInformation +import de.davis.keygo.core.item.domain.model.PasswordScore +import de.davis.keygo.core.item.domain.model.Vault +import de.davis.keygo.core.item.domain.usecase.UpsertVaultItemUseCase +import de.davis.keygo.core.security.FakeSession +import de.davis.keygo.core.security.crypto.FakeCryptographicScopeProvider +import de.davis.keygo.core.security.domain.model.CryptoScopeError +import de.davis.keygo.core.util.FakeRegistrableDomainResolver +import de.davis.keygo.core.util.Result +import de.davis.keygo.feature.item.core.domain.usecase.CreateNewOrUpdateLoginUseCase +import de.davis.keygo.feature.item.view.domain.WebsiteHandler +import de.davis.keygo.feature.password_health.FakeBreachedRepository +import de.davis.keygo.feature.password_health.FakeConnectivityRepository +import de.davis.keygo.feature.password_health.FakeHealthCheckNotifierScheduler +import de.davis.keygo.feature.password_health.FakeHealthNotificationStateRepository +import de.davis.keygo.feature.password_health.FakeHealthSettingsRepository +import de.davis.keygo.feature.password_health.FakeNotifier +import de.davis.keygo.feature.password_health.data.LoginFingerprinterImpl +import de.davis.keygo.feature.password_health.domain.PasswordHealthAttention +import de.davis.keygo.feature.password_health.domain.checker.BreachedPasswordCheck +import de.davis.keygo.feature.password_health.domain.checker.ReusePasswordCheck +import de.davis.keygo.feature.password_health.domain.checker.WeakPasswordChecker +import de.davis.keygo.feature.password_health.domain.model.FindingSeverity +import de.davis.keygo.feature.password_health.domain.model.GapReason +import de.davis.keygo.feature.password_health.domain.model.HealthReportStoreError +import de.davis.keygo.feature.password_health.domain.model.PasswordFixError +import de.davis.keygo.feature.password_health.domain.model.PasswordHealthReportError +import de.davis.keygo.feature.password_health.domain.model.StoredHealthReport +import de.davis.keygo.feature.password_health.domain.report.HealthReportAssembler +import de.davis.keygo.feature.password_health.domain.report.HealthReportScanner +import de.davis.keygo.feature.password_health.domain.report.VAULT_ID +import de.davis.keygo.feature.password_health.domain.report.id +import de.davis.keygo.feature.password_health.domain.report.login +import de.davis.keygo.feature.password_health.domain.repository.HealthReportStoreRepository +import de.davis.keygo.feature.password_health.domain.usecase.PasswordHealthReportUseCase +import de.davis.keygo.feature.password_health.domain.usecase.SetHealthNotificationsUseCase +import de.davis.keygo.feature.password_health.presentation.model.FixFlow +import de.davis.keygo.feature.password_health.presentation.model.PasswordHealthEvent +import de.davis.keygo.feature.password_health.presentation.model.PasswordHealthStatus +import de.davis.keygo.feature.password_health.presentation.model.PasswordHealthUiEvent +import de.davis.keygo.feature.password_health.presentation.model.PasswordHealthUiState +import de.davis.keygo.feature.password_health.presentation.model.RunPhase +import de.davis.keygo.rust.FakeTotpService +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import java.util.Collections +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue +import kotlin.time.Duration.Companion.milliseconds + +@OptIn(ExperimentalCoroutinesApi::class) +class PasswordHealthViewModelTest { + + private val loginRepository = FakeLoginRepository() + private val itemRepository = FakeItemRepository(loginRepository) + private val scopeProvider = FakeCryptographicScopeProvider(itemRepository) + private val vaultRepository = FakeVaultRepository() + private val breachState = FakeHealthSettingsRepository(breachesEnabled = false) + private val breached = FakeBreachedRepository() + private val websiteHandler = RecordingWebsiteHandler() + private val notificationState = FakeHealthNotificationStateRepository() + private val scheduler = FakeHealthCheckNotifierScheduler() + private val connectivity = FakeConnectivityRepository() + + @BeforeTest + fun setUp() { + Dispatchers.setMain(UnconfinedTestDispatcher()) + vaultRepository.seed( + Vault( + id = VAULT_ID, + name = "Vault", + keyInformation = KeyInformation(byteArrayOf(), byteArrayOf()), + icon = Vault.Icon.Default, + ), + ) + } + + @AfterTest + fun tearDown() = Dispatchers.resetMain() + + private fun viewModel() = PasswordHealthViewModel( + passwordHealth = PasswordHealthReportUseCase( + loginRepository = loginRepository, + loginFingerprinter = LoginFingerprinterImpl(), + healthReportStoreRepository = InMemoryStore(), + healthSettingsRepository = breachState, + scanner = HealthReportScanner( + scopeProvider, + listOf( + WeakPasswordChecker(), + ReusePasswordCheck(), + BreachedPasswordCheck(breached, breachState, connectivity), + ), + ), + assembler = HealthReportAssembler(), + attention = PasswordHealthAttention( + FakeSession(), + CoroutineScope(Dispatchers.Unconfined) + ), + healthNotificationStateRepository = notificationState, + healthCheckNotifierScheduler = scheduler, + ), + createNewOrUpdateLogin = CreateNewOrUpdateLoginUseCase( + cryptographicScopeProvider = scopeProvider, + loginRepository = loginRepository, + vaultRepository = vaultRepository, + upsertVaultItem = UpsertVaultItemUseCase(loginRepository, FakeCreditCardRepository()), + passwordStrengthEstimator = FakePasswordStrengthEstimator(PasswordScore.Strong), + totpService = FakeTotpService(), + registrableDomainResolver = FakeRegistrableDomainResolver(), + ), + healthSettingsRepository = breachState, + setHealthNotifications = SetHealthNotificationsUseCase( + healthSettingsRepository = breachState, + healthNotificationStateRepository = notificationState, + scheduler = scheduler, + notifier = FakeNotifier(), + ), + websiteHandler = websiteHandler, + connectivityRepository = connectivity, + ) + + @Test + fun startsOnTheFirstLoad() = runTest { + val vm = viewModel().also { subscribe(it) } + + assertEquals(RunPhase.FirstLoad, vm.uiState.value.phase) + assertTrue(vm.uiState.value.isFirstLoad) + } + + @Test + fun aRunReportsTheWeakPasswords() = runTest { + seed(0, score = PasswordScore.Weak) + seed(1) + val vm = viewModel().also { subscribe(it) } + + val state = runAndAwait(vm) + + assertEquals(RunPhase.Idle, state.phase) + assertEquals(2, state.totalPasswordCount) + assertEquals(PasswordHealthStatus.NEEDS_ATTENTION, state.status) + assertEquals(listOf(FindingSeverity.Medium), state.healthSections.map { it.severity }) + assertEquals(listOf(id(0)), state.healthSections.single().standalone.map { it.itemId }) + assertNull(state.error) + } + + @Test + fun reusedPasswordsAreReportedAsAGroup() = runTest { + seed(0, password = "same") + seed(1, password = "same") + val vm = viewModel().also { subscribe(it) } + + val state = runAndAwait(vm) + + assertEquals(2, state.summary.reused) + assertEquals(FindingSeverity.High, state.healthSections.single().severity) + } + + @Test + fun aCleanVaultIsAllGood() = runTest { + seed(0) + val vm = viewModel().also { subscribe(it) } + + assertEquals(PasswordHealthStatus.ALL_GOOD, runAndAwait(vm).status) + } + + @Test + fun anEmptyVaultHasNoData() = runTest { + val vm = viewModel().also { subscribe(it) } + + val state = runAndAwait(vm) + + assertEquals(PasswordHealthReportError.NoPasswords, state.error) + assertEquals(PasswordHealthStatus.NO_DATA, state.status) + assertEquals(0, state.totalPasswordCount) + } + + @Test + fun aRefreshEndsIdleWithTheNewReport() = runTest { + seed(0) + val vm = viewModel().also { subscribe(it) } + runAndAwait(vm) + + seed(1, score = PasswordScore.Weak) + vm.onEvent(PasswordHealthUiEvent.RefreshHealthCheck) + val state = vm.awaitIdle { it.totalPasswordCount == 2 } + + assertEquals(PasswordHealthStatus.NEEDS_ATTENTION, state.status) + } + + @Test + fun aRefreshLooksUpEveryPasswordInBreachesAgain() = runTest { + seed(0) + seed(1) + breachState.setBreachEnabled(true) + val vm = viewModel().also { subscribe(it) } + runAndAwait(vm) + assertEquals(2, breachLookups()) + + vm.onEvent(PasswordHealthUiEvent.RefreshHealthCheck) + vm.awaitIdle { breachLookups() == 4 } + } + + @Test + fun theRefreshAfterAFixOnlyLooksUpTheChangedPassword() = runTest { + seed(0, score = PasswordScore.Weak) + seed(1) + breachState.setBreachEnabled(true) + val vm = viewModel().also { subscribe(it) } + runAndAwait(vm) + + vm.fix(0, "n3w-Pa55word!") + vm.awaitIdle { it.fixFlow == null && it.optimisticallyFixed.isEmpty() } + + assertEquals(3, breachLookups()) + } + + @Test + fun theBreachCheckStateIsMirrored() = runTest { + val vm = viewModel().also { subscribe(it) } + + assertEquals(false, vm.uiState.value.breachCheckEnabled) + breachState.setBreachEnabled(true) + assertEquals(true, vm.uiState.first { it.breachCheckEnabled }.breachCheckEnabled) + } + + @Test + fun turningTheBreachCheckOnRunsAHealthCheck() = runTest { + seed(0, score = PasswordScore.Weak) + val vm = viewModel().also { subscribe(it) } + + vm.onEvent(PasswordHealthUiEvent.OnBreachCheckChanged(true)) + val state = vm.awaitIdle { it.totalPasswordCount == 1 } + + assertEquals(true, breachState.state.value.breachesEnabled) + assertEquals(true, state.breachCheckEnabled) + } + + @Test + fun turningTheBreachCheckOffDoesNotRunAHealthCheck() = runTest { + breachState.setBreachEnabled(true) + seed(0) + val vm = viewModel().also { subscribe(it) } + + vm.onEvent(PasswordHealthUiEvent.OnBreachCheckChanged(false)) + + assertEquals(false, breachState.state.value.breachesEnabled) + assertEquals(RunPhase.FirstLoad, vm.uiState.value.phase) + assertEquals(0, vm.uiState.value.totalPasswordCount) + } + + @Test + fun reconnectingRetriesOnlyTheUnreachableBreachLookups() = runTest { + seed(0) + seed(1) + breachState.setBreachEnabled(true) + connectivity.online = false + val vm = viewModel().also { subscribe(it) } + assertEquals(GapReason.Unreachable, runAndAwait(vm).breachGap?.reason) + + connectivity.online = true + val state = vm.awaitIdle { it.breachGap == null } + + assertEquals(2, breachLookups()) + assertEquals(2, state.totalPasswordCount) + } + + @Test + fun aConnectionThatDropsBeforeSettlingIsNotRetried() = runTest { + seed(0) + breachState.setBreachEnabled(true) + connectivity.online = false + val vm = viewModel().also { subscribe(it) } + runAndAwait(vm) + + connectivity.online = true + advanceTimeBy(500.milliseconds) + connectivity.online = false + advanceUntilIdle() + + assertEquals(0, breachLookups()) + assertEquals(GapReason.Unreachable, vm.uiState.value.breachGap?.reason) + } + + @Test + fun reconnectingWithoutAnUnreachableGapDoesNotRun() = runTest { + seed(0) + connectivity.online = false + val vm = viewModel().also { subscribe(it) } + runAndAwait(vm) + + connectivity.online = true + advanceUntilIdle() + + assertEquals(RunPhase.Idle, vm.uiState.value.phase) + assertEquals(0, breachLookups()) + } + + @Test + fun comingOnlineRightAfterOpeningStillRetries() = runTest { + seed(0) + breachState.setBreachEnabled(true) + connectivity.online = false + val vm = viewModel().also { subscribe(it) } + assertEquals(GapReason.Unreachable, runAndAwait(vm).breachGap?.reason) + + connectivity.online = true + vm.awaitIdle { it.breachGap == null } + + assertEquals(1, breachLookups()) + } + + @Test + fun clickingAnItemOpensIt() = runTest { + val vm = viewModel().also { subscribe(it) } + + vm.onEvent(PasswordHealthUiEvent.ItemClicked(id(3))) + + assertEquals(PasswordHealthEvent.OpenItem(id(3)), vm.events.first()) + } + + @Test + fun openingASiteGoesToTheWebsiteHandler() = runTest { + val vm = viewModel().also { subscribe(it) } + + vm.onEvent(PasswordHealthUiEvent.OpenSite("example.com")) + + assertEquals(listOf("example.com"), websiteHandler.opened) + } + + @Test + fun fixingStartsByGeneratingAPassword() = runTest { + val vm = viewModel().also { subscribe(it) } + + vm.onEvent(PasswordHealthUiEvent.FixClicked(id(0))) + + assertEquals(FixFlow.Generating(id(0)), vm.uiState.value.fixFlow) + } + + @Test + fun aGeneratedPasswordWaitsForConfirmation() = runTest { + val vm = viewModel().also { subscribe(it) } + + vm.onEvent(PasswordHealthUiEvent.FixClicked(id(0))) + vm.onEvent(PasswordHealthUiEvent.PasswordGenerated("n3w-Pa55word!")) + + assertEquals(FixFlow.Pending(id(0), "n3w-Pa55word!"), vm.uiState.value.fixFlow) + } + + @Test + fun aGeneratedPasswordWithoutAFixIsIgnored() = runTest { + val vm = viewModel().also { subscribe(it) } + + vm.onEvent(PasswordHealthUiEvent.PasswordGenerated("n3w-Pa55word!")) + + assertNull(vm.uiState.value.fixFlow) + } + + @Test + fun dismissingTheGeneratorCancelsTheFix() = runTest { + val vm = viewModel().also { subscribe(it) } + + vm.onEvent(PasswordHealthUiEvent.FixClicked(id(0))) + vm.onEvent(PasswordHealthUiEvent.DismissGeneratePassword) + + assertNull(vm.uiState.value.fixFlow) + } + + @Test + fun dismissingTheGeneratorKeepsAPendingFix() = runTest { + val vm = viewModel().also { subscribe(it) } + + vm.onEvent(PasswordHealthUiEvent.FixClicked(id(0))) + vm.onEvent(PasswordHealthUiEvent.PasswordGenerated("new")) + vm.onEvent(PasswordHealthUiEvent.DismissGeneratePassword) + + assertEquals(FixFlow.Pending(id(0), "new"), vm.uiState.value.fixFlow) + } + + @Test + fun discardingAPendingFixDropsIt() = runTest { + val vm = viewModel().also { subscribe(it) } + + vm.onEvent(PasswordHealthUiEvent.FixClicked(id(0))) + vm.onEvent(PasswordHealthUiEvent.PasswordGenerated("new")) + vm.onEvent(PasswordHealthUiEvent.DiscardFix) + + assertNull(vm.uiState.value.fixFlow) + } + + @Test + fun confirmingWithoutAPendingFixDoesNothing() = runTest { + seed(0, score = PasswordScore.Weak) + val vm = viewModel().also { subscribe(it) } + runAndAwait(vm) + + vm.onEvent(PasswordHealthUiEvent.FixClicked(id(0))) + vm.onEvent(PasswordHealthUiEvent.ConfirmPasswordChanged) + + assertEquals(FixFlow.Generating(id(0)), vm.uiState.value.fixFlow) + assertEquals("password-${id(0)}", passwordOf(0)) + } + + @Test + fun aConfirmedFixSavesThePasswordAndClearsTheFinding() = runTest { + seed(0, score = PasswordScore.Weak) + val vm = viewModel().also { subscribe(it) } + runAndAwait(vm) + + vm.fix(0, "n3w-Pa55word!") + val state = vm.awaitIdle { + it.fixFlow == null && it.optimisticallyFixed.isEmpty() && it.healthSections.isEmpty() + } + + assertEquals("n3w-Pa55word!", passwordOf(0)) + assertEquals(PasswordHealthStatus.ALL_GOOD, state.status) + assertTrue(state.optimisticallyFixed.isEmpty()) + } + + @Test + fun aLockedVaultKeepsTheFixPendingAsLocked() = runTest { + seed(0, score = PasswordScore.Weak) + val vm = viewModel().also { subscribe(it) } + runAndAwait(vm) + scopeProvider.itemScopeFailure = CryptoScopeError.IdNotFound + + vm.fix(0, "new") + val state = vm.uiState.first { (it.fixFlow as? FixFlow.Pending)?.error != null } + + assertEquals( + FixFlow.Pending(id(0), "new", applying = false, error = PasswordFixError.Locked), + state.fixFlow, + ) + assertTrue(state.optimisticallyFixed.isEmpty()) + assertEquals(listOf(id(0)), state.healthSections.single().standalone.map { it.itemId }) + } + + @Test + fun aFailedSaveKeepsTheFixPendingAsASaveError() = runTest { + seed(0, score = PasswordScore.Weak) + val vm = viewModel().also { subscribe(it) } + runAndAwait(vm) + loginRepository.createOrUpdateError = IllegalStateException("disk full") + + vm.fix(0, "new") + val state = vm.uiState.first { (it.fixFlow as? FixFlow.Pending)?.error != null } + + assertEquals(PasswordFixError.Save, (state.fixFlow as FixFlow.Pending).error) + assertEquals("password-${id(0)}", passwordOf(0)) + } + + @Test + fun fixingAnItemThatIsGoneIsASaveError() = runTest { + seed(0, score = PasswordScore.Weak) + val vm = viewModel().also { subscribe(it) } + runAndAwait(vm) + + vm.fix(9, "new") + val state = vm.uiState.first { (it.fixFlow as? FixFlow.Pending)?.error != null } + + assertEquals(PasswordFixError.Save, (state.fixFlow as FixFlow.Pending).error) + } + + @Test + fun aFailedFixCanBeRetried() = runTest { + seed(0, score = PasswordScore.Weak) + val vm = viewModel().also { subscribe(it) } + runAndAwait(vm) + loginRepository.createOrUpdateError = IllegalStateException("disk full") + vm.fix(0, "n3w-Pa55word!") + vm.uiState.first { (it.fixFlow as? FixFlow.Pending)?.error != null } + + vm.onEvent(PasswordHealthUiEvent.ConfirmPasswordChanged) + vm.awaitIdle { + it.fixFlow == null && it.optimisticallyFixed.isEmpty() && it.healthSections.isEmpty() + } + + assertEquals("n3w-Pa55word!", passwordOf(0)) + } + + private fun PasswordHealthViewModel.fix(n: Int, password: String) { + onEvent(PasswordHealthUiEvent.FixClicked(id(n))) + onEvent(PasswordHealthUiEvent.PasswordGenerated(password)) + onEvent(PasswordHealthUiEvent.ConfirmPasswordChanged) + } + + private fun TestScope.subscribe(vm: PasswordHealthViewModel) { + backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { vm.uiState.collect {} } + } + + private suspend fun runAndAwait(vm: PasswordHealthViewModel): PasswordHealthUiState { + vm.onEvent(PasswordHealthUiEvent.RunHealthCheck) + return vm.uiState.first { it.phase == RunPhase.Idle } + } + + private suspend fun PasswordHealthViewModel.awaitIdle( + predicate: (PasswordHealthUiState) -> Boolean, + ) = uiState.first { it.phase == RunPhase.Idle && predicate(it) } + + private fun breachLookups() = breached.calls.sumOf { it.suffixes.size } + + private fun seed( + n: Int, + password: String = "password-${id(n)}", + score: PasswordScore = PasswordScore.Strong, + ) { + itemRepository.seedEnvelope( + ItemKeyEnvelope( + vaultId = VAULT_ID, + itemId = id(n), + itemKeyInformation = KeyInformation(byteArrayOf(), byteArrayOf()), + vaultKeyInformation = KeyInformation(byteArrayOf(), byteArrayOf()), + ), + ) + loginRepository.seed(login(id(n), password = password, score = score)) + } + + private suspend fun passwordOf(n: Int): String { + val login = loginRepository.getLoginById(id(n))!! + return FakeCryptographicScopeProvider.transform( + login.passwordCredential!!.secret.payload.ciphertext, + ).decodeToString() + } + + private class RecordingWebsiteHandler : WebsiteHandler { + val opened: MutableList = Collections.synchronizedList(mutableListOf()) + + override fun openWebsite(url: String) { + opened += url + } + } + + private class InMemoryStore : HealthReportStoreRepository { + private var stored: StoredHealthReport? = null + + override suspend fun storeReport(report: StoredHealthReport): Result { + stored = report + return Result.Success(Unit) + } + + override suspend fun load(): Result = + stored?.let { Result.Success(it) } + ?: Result.Failure(HealthReportStoreError.NoReportStored) + } +} diff --git a/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/presentation/component/SeverityRampTest.kt b/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/presentation/component/SeverityRampTest.kt new file mode 100644 index 000000000..4274df5b4 --- /dev/null +++ b/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/presentation/component/SeverityRampTest.kt @@ -0,0 +1,128 @@ +package de.davis.keygo.feature.password_health.presentation.component + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.colorspace.ColorSpaces +import androidx.compose.ui.graphics.luminance +import kotlin.math.PI +import kotlin.math.abs +import kotlin.math.atan2 +import kotlin.math.max +import kotlin.math.min +import kotlin.test.Test +import kotlin.test.assertTrue + +class SeverityRampTest { + + // errorContainer, tertiaryContainer and surfaceContainerHighest of tonal-spot schemes for six + // wallpapers, light and dark, under both the 2021 and the 2025 spec. + private val containers = listOf( + 0xFF1F4D52, 0xFF21281F, 0xFF23252E, 0xFF2A2519, 0xFF2C232A, 0xFF2F4E34, 0xFF302221, + 0xFF30231C, 0xFF323630, 0xFF33343A, 0xFF38342B, 0xFF3A3337, 0xFF3D3231, 0xFF3D332D, + 0xFF49491E, 0xFF584419, 0xFF5A3D58, 0xFF663C2F, 0xFF85230A, 0xFF871C34, 0xFF871F21, + 0xFF93000A, 0xFFBCEBF0, 0xFFC8ECC9, 0xFFDEE5D8, 0xFFDFCCFD, 0xFFE0E4DB, 0xFFE1E2ED, + 0xFFE3E2E9, 0xFFE8E5AC, 0xFFE9CDFD, 0xFFEAE2D4, 0xFFECDFE5, 0xFFECE1CE, 0xFFEDEFAC, + 0xFFEEDEE8, 0xFFF0DFD7, 0xFFF1DEDC, 0xFFF5DED2, 0xFFF6DDDA, 0xFFF97386, 0xFFF9C98E, + 0xFFFA746F, 0xFFFAE8A2, 0xFFFCFEB9, 0xFFFD795A, 0xFFFEC392, 0xFFFEDEA6, 0xFFFFD6F8, + 0xFFFFDAD6, 0xFFFFDBD0, + ).map { Color(it) } + + @Test + fun everyStepReachesItsContrastOnEveryCard() { + containers.forEach { container -> + val ramp = severityRamp(container) + listOf( + ramp.critical to CriticalContrast, + ramp.high to HighContrast, + ramp.medium to MediumContrast, + ).forEach { (color, target) -> + val ratio = contrast(color, container) + assertTrue(ratio >= target, "$color on $container is $ratio, needs $target") + } + } + } + + @Test + fun anAccentTintsANeutralCardAndStillReachesItsContrast() { + val cards = listOf(0xFFF0EAF1, 0xFF2B292D).map { Color(it) } + val accents = listOf(0xFF7D5260, 0xFFEFB8C8, 0xFF625B71, 0xFF006A6A).map { Color(it) } + cards.forEach { card -> + accents.forEach { accent -> + val ramp = severityRamp(card, accent) + listOf( + ramp.critical to CriticalContrast, + ramp.high to HighContrast, + ramp.medium to MediumContrast, + ).forEach { (color, target) -> + val ratio = contrast(color, card) + assertTrue(ratio >= target, "$color on $card is $ratio, needs $target") + val drift = hueDistance(hue(color), hue(accent)) + assertTrue(drift < 10.0, "$color drifted $drift degrees from $accent") + } + } + } + } + + @Test + fun criticalStandsOutMostAndMediumLeast() { + containers.forEach { container -> + val ramp = severityRamp(container) + val critical = contrast(ramp.critical, container) + val high = contrast(ramp.high, container) + val medium = contrast(ramp.medium, container) + assertTrue(critical > high && high > medium, "ramp on $container is out of order") + } + } + + @Test + fun criticalIsTheMostVividAndMediumTheCalmest() { + containers.forEach { container -> + val ramp = severityRamp(container) + assertTrue( + ramp.critical.chroma() > ramp.high.chroma() && + ramp.high.chroma() > ramp.medium.chroma(), + "vividness on $container is out of order", + ) + } + } + + @Test + fun aNeutralCardKeepsTheRampMuted() { + val ramp = severityRamp(Color(0xFFECDFE5)) + assertTrue(ramp.critical.chroma() < 40f, "${ramp.critical} is too saturated") + } + + @Test + fun aGreyCardGetsAGreyRamp() { + val ramp = severityRamp(Color(0xFFEBEBEB)) + listOf(ramp.critical, ramp.high, ramp.medium).forEach { + assertTrue(it.chroma() < 2f, "$it is tinted") + } + } + + @Test + fun theRampKeepsTheCardsHue() { + listOf(0xFFFFDAD6, 0xFF93000A, 0xFFDFCCFD, 0xFFC8ECC9).map { Color(it) }.forEach { card -> + val ramp = severityRamp(card) + listOf(ramp.critical, ramp.high, ramp.medium).forEach { color -> + val drift = hueDistance(hue(color), hue(card)) + assertTrue(drift < 10.0, "$color drifted $drift degrees from $card") + } + } + } + + private fun contrast(a: Color, b: Color): Float { + val la = a.luminance() + 0.05f + val lb = b.luminance() + 0.05f + return max(la, lb) / min(la, lb) + } + + private fun hue(color: Color): Double { + val lab = color.convert(ColorSpaces.CieLab) + return atan2(lab.blue.toDouble(), lab.green.toDouble()) * 180 / PI + } + + private fun hueDistance(a: Double, b: Double): Double { + val d = abs(a - b) % 360 + return min(d, 360 - d) + } +} diff --git a/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/presentation/model/PasswordHealthUiStateTest.kt b/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/presentation/model/PasswordHealthUiStateTest.kt new file mode 100644 index 000000000..95811fd29 --- /dev/null +++ b/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/presentation/model/PasswordHealthUiStateTest.kt @@ -0,0 +1,497 @@ +package de.davis.keygo.feature.password_health.presentation.model + +import de.davis.keygo.core.item.domain.alias.ItemId +import de.davis.keygo.core.item.domain.model.PasswordScore +import de.davis.keygo.feature.password_health.domain.model.CheckGap +import de.davis.keygo.feature.password_health.domain.model.CheckKind +import de.davis.keygo.feature.password_health.domain.model.FindingSeverity +import de.davis.keygo.feature.password_health.domain.model.GapReason +import de.davis.keygo.feature.password_health.domain.model.HealthFinding +import de.davis.keygo.feature.password_health.domain.model.ItemHealth +import de.davis.keygo.feature.password_health.domain.model.ItemIssue +import de.davis.keygo.feature.password_health.domain.model.PasswordHealthReport +import de.davis.keygo.feature.password_health.domain.model.PasswordHealthReportError +import de.davis.keygo.feature.password_health.domain.model.RelatedGroup +import de.davis.keygo.feature.password_health.domain.model.RelationType +import java.util.UUID +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertSame +import kotlin.test.assertTrue + +class PasswordHealthUiStateTest { + + private val weak = ItemIssue.Weak(PasswordScore.Weak) + private val breached = ItemIssue.Breached(3) + + @Test + fun anEmptyVaultHasNoData() { + assertEquals(PasswordHealthStatus.NO_DATA, PasswordHealthUiState().status) + } + + @Test + fun noPasswordsIsNoDataRatherThanAFailure() { + val state = PasswordHealthUiState(error = PasswordHealthReportError.NoPasswords) + + assertEquals(PasswordHealthStatus.NO_DATA, state.status) + } + + @Test + fun anyOtherErrorIsAFailure() { + val state = PasswordHealthUiState( + error = PasswordHealthReportError.Unreadable, + totalPasswordCount = 4, + ) + + assertEquals(PasswordHealthStatus.FAILED, state.status) + } + + @Test + fun checkedPasswordsWithoutFindingsAreAllGood() { + assertEquals( + PasswordHealthStatus.ALL_GOOD, + PasswordHealthUiState(totalPasswordCount = 3).status + ) + } + + @Test + fun anyFindingNeedsAttention() { + val state = PasswordHealthUiState( + totalPasswordCount = 3, + reportedSections = listOf( + section( + FindingSeverity.Medium, + standalone = listOf(item(0, weak)) + ) + ), + ) + + assertEquals(PasswordHealthStatus.NEEDS_ATTENTION, state.status) + } + + @Test + fun fixingEveryFindingIsAllGood() { + val state = PasswordHealthUiState( + totalPasswordCount = 3, + reportedSections = listOf( + section( + FindingSeverity.Medium, + standalone = listOf(item(0, weak)) + ) + ), + optimisticallyFixed = setOf(id(0)), + ) + + assertEquals(PasswordHealthStatus.ALL_GOOD, state.status) + assertTrue(state.healthSections.isEmpty()) + } + + @Test + fun phasesAreReportedAsFlags() { + assertTrue(PasswordHealthUiState(phase = RunPhase.FirstLoad).isFirstLoad) + assertTrue(PasswordHealthUiState(phase = RunPhase.Refresh).isRefreshing) + PasswordHealthUiState(phase = RunPhase.Idle).let { + assertFalse(it.isFirstLoad) + assertFalse(it.isRefreshing) + } + } + + @Test + fun theFixFlowIsExposedByStep() { + val generating = PasswordHealthUiState(fixFlow = FixFlow.Generating(id(0))) + val pending = PasswordHealthUiState(fixFlow = FixFlow.Pending(id(0), "new")) + + assertEquals(FixFlow.Generating(id(0)), generating.generatingFix) + assertNull(generating.pendingFix) + assertEquals(FixFlow.Pending(id(0), "new"), pending.pendingFix) + assertNull(pending.generatingFix) + } + + @Test + fun exposesTheBreachGapAlone() { + val gap = CheckGap(GapReason.Unreachable, setOf(id(0))) + val state = PasswordHealthUiState( + checkGaps = mapOf( + CheckKind.Breach to gap, + CheckKind.Similarity to CheckGap(GapReason.Failed, setOf(id(1))), + ), + ) + + assertEquals(gap, state.breachGap) + assertNull(PasswordHealthUiState().breachGap) + } + + @Test + fun nothingFixedLeavesTheSectionsAsTheyAre() { + val sections = listOf(section(FindingSeverity.Medium, standalone = listOf(item(0, weak)))) + + assertSame(sections, sections.withoutFixed(emptySet())) + } + + @Test + fun aFixedStandaloneItemLeavesItsSection() { + val sections = listOf( + section(FindingSeverity.Medium, standalone = listOf(item(0, weak), item(1, weak))), + ) + + val remaining = sections.withoutFixed(setOf(id(0))) + + assertEquals(listOf(id(1)), remaining.single().standalone.map { it.itemId }) + } + + @Test + fun aSectionLeftEmptyIsDropped() { + val sections = listOf( + section(FindingSeverity.Critical, standalone = listOf(item(0, breached))), + section(FindingSeverity.Medium, standalone = listOf(item(1, weak))), + ) + + val remaining = sections.withoutFixed(setOf(id(0))) + + assertEquals(listOf(FindingSeverity.Medium), remaining.map { it.severity }) + } + + @Test + fun aGroupThatKeepsTwoMembersStaysAGroup() { + val group = group(RelationType.Reused, item(0), item(1), item(2)) + val sections = listOf(section(FindingSeverity.High, groups = listOf(group))) + + val remaining = sections.withoutFixed(setOf(id(0))).single() + + assertEquals( + setOf(id(1), id(2)), + remaining.groups.single().members.mapTo(mutableSetOf()) { it.itemId }, + ) + assertTrue(remaining.standalone.isEmpty()) + } + + @Test + fun aGroupLeftWithOneCleanMemberDisappears() { + val sections = listOf( + section( + FindingSeverity.High, + groups = listOf(group(RelationType.Reused, item(0), item(1))) + ), + ) + + assertTrue(sections.withoutFixed(setOf(id(0))).isEmpty()) + } + + @Test + fun aGroupLeftWithOneFlaggedMemberFallsBackToStandalone() { + val sections = listOf( + section( + FindingSeverity.Critical, + groups = listOf(group(RelationType.Reused, item(0), item(1, breached))), + standalone = listOf(item(2, weak)), + ), + ) + + val remaining = sections.withoutFixed(setOf(id(0))).single() + + assertTrue(remaining.groups.isEmpty()) + assertEquals(listOf(id(1), id(2)), remaining.standalone.map { it.itemId }) + } + + @Test + fun standaloneItemsAreClusteredByTheirWorstIssueBreachedFirst() { + val section = section( + FindingSeverity.Critical, + standalone = listOf(item(0, weak), item(1, breached, weak), item(2, breached)), + ) + + assertEquals( + listOf( + StandaloneIssue.Breached to listOf(id(1), id(2)), + StandaloneIssue.Weak to listOf(id(0)), + ), + section.standaloneClusters.map { cluster -> cluster.issue to cluster.items.map { it.itemId } }, + ) + } + + @Test + fun noStandaloneItemsMeansNoClusters() { + val section = section( + FindingSeverity.High, + groups = listOf(group(RelationType.Reused, item(0), item(1))) + ) + + assertTrue(section.standaloneClusters.isEmpty()) + } + + @Test + fun sectionsRunFromCriticalToMedium() { + val report = report( + groups = listOf( + group(RelationType.Similar, item(0), item(1)), + group(RelationType.Reused, item(4), item(5)), + ), + standalone = listOf(item(2, weak), item(3, breached)), + ) + + assertEquals( + listOf(FindingSeverity.Critical, FindingSeverity.High, FindingSeverity.Medium), + report.toSections().map { it.severity }, + ) + } + + @Test + fun largerGroupsComeFirst() { + val pair = group(RelationType.Reused, item(0, title = "A"), item(1, title = "B")) + val trio = group(RelationType.Reused, item(2, title = "X"), item(3), item(4)) + + val section = report(groups = listOf(pair, trio)).toSections().single() + + assertEquals(listOf(trio.id, pair.id), section.groups.map { it.id }) + } + + @Test + fun groupsOfTheSameSizeFollowTheNaturalOrderOfTheirFirstMember() { + val ten = group(RelationType.Reused, item(0, title = "Server 10"), item(1, title = "Zulu")) + val two = group(RelationType.Reused, item(2, title = "Server 2"), item(3, title = "Zulu")) + + val section = report(groups = listOf(ten, two)).toSections().single() + + assertEquals(listOf(two.id, ten.id), section.groups.map { it.id }) + } + + @Test + fun groupMembersAreOrderedBySeverityThenNaturally() { + val group = group( + RelationType.Reused, + item(0, title = "Item 10"), + item(1, breached, title = "Zulu"), + item(2, title = "item 2"), + ) + + assertEquals(listOf(id(1), id(2), id(0)), group.orderedMembers.map { it.itemId }) + } + + @Test + fun standaloneItemsAreOrderedNaturallyWhateverTheirIssueCount() { + val section = report( + standalone = listOf( + item(0, breached, title = "Mail 10"), + item(1, breached, title = "Mail 2"), + item(2, breached, weak, title = "Zulu"), + ), + ).toSections().single() + + assertEquals(listOf(id(1), id(0), id(2)), section.standalone.map { it.itemId }) + } + + @Test + fun groupsReorderWhenAFixShrinksThem() { + val trio = group( + RelationType.Reused, + item(0, title = "Server 10"), + item(1, title = "Zulu 1"), + item(2, title = "Zulu 2"), + ) + val pair = + group(RelationType.Reused, item(3, title = "Server 2"), item(4, title = "Zulu 3")) + val sections = report(groups = listOf(trio, pair)).toSections() + assertEquals(listOf(trio.id, pair.id), sections.single().groups.map { it.id }) + + val remaining = sections.withoutFixed(setOf(id(1))).single() + + assertEquals(listOf(pair.id, trio.id), remaining.groups.map { it.id }) + } + + @Test + fun summaryCountsEveryFlaggedItemOnce() { + val sections = listOf( + section( + FindingSeverity.Critical, + groups = listOf(group(RelationType.Reused, item(0, breached, weak), item(1))), + standalone = listOf(item(2, breached)), + ), + section(FindingSeverity.Medium, standalone = listOf(item(3, weak))), + ) + + assertEquals( + HealthSummary(needsAttention = 4, weak = 2, breached = 2, reused = 2, similar = 0), + sections.summary(), + ) + } + + @Test + fun summaryCountsTheItemsCoveredByEachRelation() { + val mixed = RelatedGroup( + id = id(0), + members = (0..3).mapTo(mutableSetOf()) { item(it) }, + relations = setOf( + HealthFinding.Relation(setOf(id(0), id(1)), RelationType.Reused), + HealthFinding.Relation(setOf(id(1), id(2), id(3)), RelationType.Similar), + ), + ) + + val summary = listOf(section(FindingSeverity.High, groups = listOf(mixed))).summary() + + assertEquals(2, summary.reused) + assertEquals(3, summary.similar) + assertEquals(4, summary.needsAttention) + } + + @Test + fun summaryOfNothingIsAllZero() { + assertEquals(HealthSummary(0, 0, 0, 0, 0), emptyList().summary()) + } + + @Test + fun theSummaryFollowsTheFixedItems() { + val state = PasswordHealthUiState( + totalPasswordCount = 2, + reportedSections = listOf( + section(FindingSeverity.Medium, standalone = listOf(item(0, weak), item(1, weak))), + ), + optimisticallyFixed = setOf(id(0)), + ) + + assertEquals(1, state.summary.needsAttention) + assertEquals(1, state.summary.weak) + } + + @Test + fun theWorstSeverityIsTheTopSection() { + val state = PasswordHealthUiState( + totalPasswordCount = 3, + reportedSections = listOf( + section( + FindingSeverity.High, + groups = listOf(group(RelationType.Reused, item(0), item(1))), + ), + section(FindingSeverity.Medium, standalone = listOf(item(2, weak))), + ), + ) + + assertEquals(FindingSeverity.High, state.worstSeverity) + } + + @Test + fun theWorstSeverityDropsOnceItsItemsAreFixed() { + val state = PasswordHealthUiState( + totalPasswordCount = 2, + reportedSections = listOf( + section(FindingSeverity.Critical, standalone = listOf(item(0, breached))), + section(FindingSeverity.Medium, standalone = listOf(item(1, weak))), + ), + optimisticallyFixed = setOf(id(0)), + ) + + assertEquals(FindingSeverity.Medium, state.worstSeverity) + } + + @Test + fun nothingFlaggedHasNoWorstSeverity() { + assertNull(PasswordHealthUiState(totalPasswordCount = 3).worstSeverity) + } + + @Test + fun theBreakdownPutsEachPasswordUnderItsWorstIssue() { + val sections = listOf( + section( + FindingSeverity.Critical, + groups = listOf( + group(RelationType.Similar, item(0, breached), item(1), item(2, weak)), + ), + standalone = listOf(item(3, breached, weak)), + ), + section( + FindingSeverity.High, + groups = listOf(group(RelationType.Reused, item(4, weak), item(5))), + ), + section(FindingSeverity.Medium, standalone = listOf(item(6, weak))), + ) + + assertEquals( + SeverityBreakdown(critical = 2, high = 2, medium = 3, clean = 3), + sections.breakdown(checked = 10), + ) + } + + @Test + fun aGroupMemberTakesTheSeverityOfItsOwnRelationOnly() { + val mixed = RelatedGroup( + id = id(0), + members = (0..2).mapTo(mutableSetOf()) { item(it) }, + relations = setOf( + HealthFinding.Relation(setOf(id(0), id(1)), RelationType.Reused), + HealthFinding.Relation(setOf(id(1), id(2)), RelationType.Similar), + ), + ) + + assertEquals( + SeverityBreakdown(critical = 0, high = 2, medium = 1, clean = 0), + listOf(section(FindingSeverity.High, groups = listOf(mixed))).breakdown(checked = 3), + ) + } + + @Test + fun unreadablePasswordsAreLeftOutOfTheBreakdown() { + val state = PasswordHealthUiState( + totalPasswordCount = 5, + reportedSections = listOf( + section(FindingSeverity.Medium, standalone = listOf(item(0, weak))), + ), + unreadable = setOf(id(8), id(9)), + ) + + assertEquals( + SeverityBreakdown(critical = 0, high = 0, medium = 1, clean = 2), + state.breakdown, + ) + } + + @Test + fun aFixedPasswordMovesToClean() { + val state = PasswordHealthUiState( + totalPasswordCount = 4, + reportedSections = listOf( + section( + FindingSeverity.Critical, + standalone = listOf(item(0, breached), item(1, breached)), + ), + ), + optimisticallyFixed = setOf(id(0)), + ) + + assertEquals( + SeverityBreakdown(critical = 1, high = 0, medium = 0, clean = 3), + state.breakdown, + ) + } + + private fun id(n: Int): ItemId = UUID(0L, n.toLong()) + + private fun item(n: Int, vararg issues: ItemIssue, title: String = "item-$n") = ItemHealth( + itemId = id(n), + title = title, + username = null, + issues = issues.toList(), + ) + + private fun group(type: RelationType, vararg members: ItemHealth) = RelatedGroup( + id = members.first().itemId, + members = members.toSet(), + relations = setOf( + HealthFinding.Relation( + members.mapTo(mutableSetOf()) { it.itemId }, + type + ) + ), + ) + + private fun report( + groups: List = emptyList(), + standalone: List = emptyList(), + ) = PasswordHealthReport(groups, standalone, totalPasswordsScanned = 10) + + private fun section( + severity: FindingSeverity, + groups: List = emptyList(), + standalone: List = emptyList(), + ) = HealthSection(severity, groups, standalone) +} diff --git a/feature/vault/src/main/kotlin/de/davis/keygo/feature/vault/domain/usecase/ObserveVaultsAndSelectionUseCase.kt b/feature/vault/src/main/kotlin/de/davis/keygo/feature/vault/domain/usecase/ObserveVaultsAndSelectionUseCase.kt index 2e660578f..b6da8d6be 100644 --- a/feature/vault/src/main/kotlin/de/davis/keygo/feature/vault/domain/usecase/ObserveVaultsAndSelectionUseCase.kt +++ b/feature/vault/src/main/kotlin/de/davis/keygo/feature/vault/domain/usecase/ObserveVaultsAndSelectionUseCase.kt @@ -2,7 +2,7 @@ package de.davis.keygo.feature.vault.domain.usecase import de.davis.keygo.core.item.domain.repository.VaultContextRepository import de.davis.keygo.core.item.domain.repository.VaultRepository -import de.davis.keygo.core.util.domain.usecase.SortUseCase +import de.davis.keygo.core.util.domain.comparator.sortedNaturallyBy import de.davis.keygo.feature.vault.domain.model.VaultsAndSelection import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.combine @@ -12,14 +12,13 @@ import org.koin.core.annotation.Single class ObserveVaultsAndSelectionUseCase( private val vaultRepository: VaultRepository, private val vaultContextRepository: VaultContextRepository, - private val sortUseCase: SortUseCase, ) { operator fun invoke(): Flow = combine( vaultRepository.observeAllVaultMetadata(), vaultContextRepository.observeVaultContext(), ) { vaults, context -> VaultsAndSelection( - vaults = sortUseCase(vaults) { it.name }, + vaults = vaults.sortedNaturallyBy { it.name }, selection = context, ) } diff --git a/feature/vault/src/test/kotlin/de/davis/keygo/feature/vault/domain/usecase/MoveItemsToVaultUseCaseTest.kt b/feature/vault/src/test/kotlin/de/davis/keygo/feature/vault/domain/usecase/MoveItemsToVaultUseCaseTest.kt index 2c491a34d..0bb19296c 100644 --- a/feature/vault/src/test/kotlin/de/davis/keygo/feature/vault/domain/usecase/MoveItemsToVaultUseCaseTest.kt +++ b/feature/vault/src/test/kotlin/de/davis/keygo/feature/vault/domain/usecase/MoveItemsToVaultUseCaseTest.kt @@ -34,6 +34,7 @@ import de.davis.keygo.feature.vault.domain.model.MoveItemsError import de.davis.keygo.feature.vault.domain.model.MoveItemsProgress import de.davis.keygo.rust.FakeItemManager import de.davis.keygo.rust.FakeKeyWrapper +import de.davis.keygo.rust.FakeVaultManager import de.davisalessandro.keygo.rust.ItemAad import de.davisalessandro.keygo.rust.KeyWrapException import kotlinx.coroutines.runBlocking @@ -54,8 +55,15 @@ class MoveItemsToVaultUseCaseTest { private val itemRepository = FakeItemRepository(loginRepository) private val itemManager = FakeItemManager() private val keyWrapper = FakeKeyWrapper() + private val vaultManager = FakeVaultManager() private val cryptographicScopeProvider: CryptographicScopeProvider = - BindingCryptographicScopeProvider(session, itemRepository, itemManager, keyWrapper) + BindingCryptographicScopeProvider( + session, + itemRepository, + itemManager, + keyWrapper, + vaultManager + ) private val vaultRepository = FakeVaultRepository() diff --git a/feature/vault/src/test/kotlin/de/davis/keygo/feature/vault/domain/usecase/ObserveVaultsAndSelectionUseCaseTest.kt b/feature/vault/src/test/kotlin/de/davis/keygo/feature/vault/domain/usecase/ObserveVaultsAndSelectionUseCaseTest.kt index 2e1d21c7f..8fd49cbcb 100644 --- a/feature/vault/src/test/kotlin/de/davis/keygo/feature/vault/domain/usecase/ObserveVaultsAndSelectionUseCaseTest.kt +++ b/feature/vault/src/test/kotlin/de/davis/keygo/feature/vault/domain/usecase/ObserveVaultsAndSelectionUseCaseTest.kt @@ -7,7 +7,6 @@ import de.davis.keygo.core.item.domain.alias.newVaultId import de.davis.keygo.core.item.domain.model.KeyInformation import de.davis.keygo.core.item.domain.model.Vault import de.davis.keygo.core.item.domain.model.VaultContext -import de.davis.keygo.core.util.domain.usecase.SortUseCase import kotlinx.coroutines.flow.first import kotlinx.coroutines.test.runTest import kotlin.test.Test @@ -24,7 +23,6 @@ class ObserveVaultsAndSelectionUseCaseTest { private val useCase = ObserveVaultsAndSelectionUseCase( vaultRepository = vaultRepository, vaultContextRepository = vaultContextRepository, - sortUseCase = SortUseCase(), ) @Test diff --git a/settings.gradle.kts b/settings.gradle.kts index 57c645075..6cdf5b03d 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -51,3 +51,4 @@ include(":feature:credit-card") include(":feature:settings") include(":feature:backup") include(":feature:onboarding") +include(":feature:password-health")