diff --git a/PLATFORMS.md b/PLATFORMS.md index ea6184a11..80df0bb67 100644 --- a/PLATFORMS.md +++ b/PLATFORMS.md @@ -4,7 +4,7 @@ This document defines the platform boundary for Nextcloud Native. It separates portable product behavior from operating-system integration so shared code does not erase native security, lifecycle, accessibility, or filesystem semantics. -**Last reviewed: 2026-08-20.** Implementation and release availability may +**Last reviewed: 2026-09-01.** Implementation and release availability may have changed. The [GitHub Releases page](https://github.com/Obiente/nc-native/releases) is the source of truth for published artifacts and their limitations. @@ -28,7 +28,7 @@ its platform acceptance criteria pass and its limitations are documented. | Android | Compose Multiplatform | Active launcher and signed alpha APK/AAB | Keystore, WorkManager, DocumentsProvider, permissions, notifications, shares, media sessions, camera backup, and calls | | Linux | Compose Desktop | Primary interactive desktop target; alpha RPM/DEB | Secret Service, desktop file integration, notifications, media keys, portals, and conventional sync roots | | Windows | Compose Desktop | Unsigned x86-64 MSI with Credential Manager, attested builds, and Cloud Files integration under prerelease qualification | Explorer validation, free trusted signing when available, notifications, media controls, and updates | -| macOS | Compose Desktop | Early DMG packaging artifact; no supported authenticated login yet | Keychain, File Provider/Finder integration, notifications, media controls, and updates | +| macOS | Compose Desktop | Early DMG packaging artifact; Keychain storage is source-tested, but authenticated use has not been live-validated or qualified | Keychain, File Provider/Finder integration, notifications, media controls, and updates | | iOS / iPadOS | Planned Compose target | No supported launcher is shipped | Keychain, File Provider, background transfer, share extension, notifications, media, and CallKit | Packaging is not feature parity. A platform becomes supported for a workflow diff --git a/README.md b/README.md index cc83889af..22044cb1e 100644 --- a/README.md +++ b/README.md @@ -159,7 +159,7 @@ boundaries. ## Implemented alpha surfaces -**Last reviewed: 2026-08-20.** Repository implementation may have changed. The +**Last reviewed: 2026-09-01.** Repository implementation may have changed. The [default branch](https://github.com/Obiente/nc-native/tree/main) is the source of truth for current code. A listed surface can still have platform, version, action, or lifecycle limitations and is not a shipped-support guarantee. @@ -167,8 +167,8 @@ action, or lifecycle limitations and is not a shipped-support guarantee. The repository already contains runnable Android and Linux desktop applications with: -- Nextcloud Login Flow v2 with Android Keystore and Linux Secret Service - credential storage; +- Nextcloud Login Flow v2 with Android Keystore, Linux Secret Service, Windows + Credential Manager, and source-tested macOS Keychain credential storage; - authenticated native Files browsing, list/grid layouts, previews, sharing foundations, text editing, and media viewing; - Photos and Memories collections, albums, tags, people, favorites, RAW/JPEG @@ -220,7 +220,7 @@ The dependency gates and data-safety criteria are in ## Platform status -**Last reviewed: 2026-08-20.** Platform availability may have changed. The +**Last reviewed: 2026-09-01.** Platform availability may have changed. The [GitHub Releases page](https://github.com/Obiente/nc-native/releases) is the source of truth for published artifacts and limitations. This table is not a stable-support guarantee. @@ -230,7 +230,7 @@ stable-support guarantee. | Android | Active application target with signed APK/AAB prereleases; hosted CI covers unit tests and packaging, while connected-device instrumentation remains separate | | Linux | Primary interactive desktop development target, distributable plus RPM/DEB prereleases | | Windows | x86-64 MSI, native Credential Manager login storage, and Cloud Files sync under active prerelease qualification | -| macOS | Early DMG packaging artifact; native Keychain login storage and supported authenticated use are not implemented yet | +| macOS | Early DMG packaging artifact; native Keychain storage is covered by deterministic source tests, but authenticated use has not been live-validated or qualified | | iOS / iPadOS | Planned platform target; no supported launcher is shipped yet | Android and desktop already share domain models, semantic components, and @@ -278,8 +278,9 @@ Android release artifacts are signed with the project's protected release key. Desktop packages are provided per successful platform build. Windows MSI packages use native Credential Manager storage, include keyless GitHub build provenance, and are currently unsigned, so SmartScreen may require choosing -`More info > Run anyway`. macOS packages still prove packaging only and do not -yet have native Keychain login integration. +`More info > Run anyway`. The source includes deterministically tested macOS +Keychain integration, but the macOS package remains a packaging artifact until +authenticated use passes a live macOS acceptance run. Read each release's known limitations before installing over an existing test build. diff --git a/changes/unreleased/335-macos-keychain-secret-store.md b/changes/unreleased/335-macos-keychain-secret-store.md new file mode 100644 index 000000000..969ff8ef7 --- /dev/null +++ b/changes/unreleased/335-macos-keychain-secret-store.md @@ -0,0 +1,7 @@ +category: fix +issue: 335 +pull: 430 +platforms: macos +user-facing: yes + +Store desktop login credentials and Deck draft keys in macOS Keychain, migrate Secret Service values, and keep locked access and sign-out cleanup retryable. Preserve encrypted Deck drafts instead of replacing a missing key. diff --git a/tools/kotlin-file-size-baseline.txt b/tools/kotlin-file-size-baseline.txt index bce4b798b..8746c0f05 100644 --- a/tools/kotlin-file-size-baseline.txt +++ b/tools/kotlin-file-size-baseline.txt @@ -25,7 +25,7 @@ ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NativeDeckBoardSurface. ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NativeDeckRelationDialogs.kt|1234 ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NativeDeckScreen.kt|1937 ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudMediaViewer.kt|1331 -ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNativeApp.kt|12436 +ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNativeApp.kt|12435 ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNotes.kt|1693 ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudPhotoEditor.kt|808 ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudPlatform.kt|1755 diff --git a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNativeApp.kt b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNativeApp.kt index 7a8b43e39..4dfff0917 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNativeApp.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNativeApp.kt @@ -534,15 +534,42 @@ fun NextcloudNativeApp( NextcloudNativeTheme(darkTheme = darkTheme) { NextcloudAppBackground { - var session by remember { mutableStateOf(services.loadSession()) } - if (session == null) { + var sessionLoadAttempt by remember { mutableStateOf(0) } + val sessionLoad = remember(services, sessionLoadAttempt) { + loadNextcloudSessionSafely(services::loadSession) + } + var session by remember(services, sessionLoadAttempt) { + mutableStateOf((sessionLoad as? NextcloudSessionLoadState.Loaded)?.session) + } + val signInAgain = { + scope.launch { + try { + services.clearSession() + sessionLoadAttempt += 1 + } catch (failure: CancellationException) { + throw failure + } catch (_: NextcloudSessionStorageUnavailableException) { + // Keep the recoverable storage screen visible when cleanup could not be queued safely. + } + } + Unit + } + if (sessionLoad == NextcloudSessionLoadState.SecureStorageUnavailable) { + SecureSessionStorageUnavailable( + onRetry = { sessionLoadAttempt += 1 }, + onSignInAgain = signInAgain, + ) + } else if (sessionLoad == NextcloudSessionLoadState.LegacyMigrationUnavailable) { + LegacySessionMigrationUnavailable( + onRetry = { sessionLoadAttempt += 1 }, + onSignInAgain = signInAgain, + ) + } else if (session == null) { if (pendingAppUpdateReviewRequest != null) { LoggedOutAppUpdateReviewScreen( services = services, platformCapabilityRefreshRequest = platformCapabilityRefreshRequest, - onContinueToSignIn = { - handledAppUpdateReviewRequest = pendingAppUpdateReviewRequest - }, + onContinueToSignIn = { handledAppUpdateReviewRequest = pendingAppUpdateReviewRequest }, ) } else { LoginScreen( @@ -12367,34 +12394,6 @@ internal fun SectionTitle(text: String, modifier: Modifier = Modifier) { Text(text, modifier = modifier, style = MaterialTheme.typography.titleLarge, color = MaterialTheme.colorScheme.primary) } -@Composable -internal fun LoadingMessage(message: String) { - Column( - modifier = Modifier.fillMaxSize(), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center, - ) { - CircularProgressIndicator() - Text(message, modifier = Modifier.padding(top = NextcloudSpacing.Large)) - } -} - -@Composable -internal fun EmptyMessage(message: String) { - Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - Text(message, modifier = Modifier.padding(NextcloudSpacing.XLarge), color = MaterialTheme.colorScheme.onSurfaceVariant) - } -} - -@Composable -internal fun ErrorMessage(message: String, onRetry: (() -> Unit)? = null) { - Column(modifier = Modifier.padding(NextcloudSpacing.XLarge), verticalArrangement = Arrangement.spacedBy(12.dp)) { - Icon(NextcloudIcons.Error, contentDescription = null, tint = MaterialTheme.colorScheme.error) - Text(message, color = MaterialTheme.colorScheme.error) - onRetry?.let { retry -> OutlinedButton(onClick = retry) { Text("Try again") } } - } -} - @Composable private fun RetainedRefreshError(message: String, onRetry: () -> Unit, modifier: Modifier = Modifier) { RetainedContentNotice(message, onRetry, modifier.padding(horizontal = NextcloudSpacing.Large, vertical = NextcloudSpacing.Small)) diff --git a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudSessionLoading.kt b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudSessionLoading.kt new file mode 100644 index 000000000..189cd6118 --- /dev/null +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudSessionLoading.kt @@ -0,0 +1,35 @@ +package dev.obiente.nextcloudnative.app + +import kotlinx.coroutines.CancellationException + +internal open class NextcloudSessionStorageUnavailableException( + message: String, + cause: Throwable? = null, +) : IllegalStateException(message, cause) + +internal class NextcloudSessionLegacyMigrationUnavailableException( + cause: Throwable, +) : NextcloudSessionStorageUnavailableException( + "The legacy secure-storage provider required for session migration is unavailable.", + cause, +) + +internal sealed interface NextcloudSessionLoadState { + data class Loaded(val session: NextcloudSession?) : NextcloudSessionLoadState + + data object SecureStorageUnavailable : NextcloudSessionLoadState + + data object LegacyMigrationUnavailable : NextcloudSessionLoadState +} + +internal fun loadNextcloudSessionSafely( + loadSession: () -> NextcloudSession?, +): NextcloudSessionLoadState = try { + NextcloudSessionLoadState.Loaded(loadSession()) +} catch (failure: CancellationException) { + throw failure +} catch (_: NextcloudSessionLegacyMigrationUnavailableException) { + NextcloudSessionLoadState.LegacyMigrationUnavailable +} catch (_: NextcloudSessionStorageUnavailableException) { + NextcloudSessionLoadState.SecureStorageUnavailable +} diff --git a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudStatusMessages.kt b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudStatusMessages.kt new file mode 100644 index 000000000..3c98bcecb --- /dev/null +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudStatusMessages.kt @@ -0,0 +1,90 @@ +package dev.obiente.nextcloudnative.app + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import dev.obiente.nextcloudnative.app.design.NextcloudIcons +import dev.obiente.nextcloudnative.app.design.NextcloudSpacing + +@Composable +internal fun LoadingMessage(message: String) { + Column( + modifier = Modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + CircularProgressIndicator() + Text(message, modifier = Modifier.padding(top = NextcloudSpacing.Large)) + } +} + +@Composable +internal fun EmptyMessage(message: String) { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Text(message, modifier = Modifier.padding(NextcloudSpacing.XLarge), color = MaterialTheme.colorScheme.onSurfaceVariant) + } +} + +@Composable +internal fun ErrorMessage(message: String, onRetry: (() -> Unit)? = null) { + Column(modifier = Modifier.padding(NextcloudSpacing.XLarge), verticalArrangement = Arrangement.spacedBy(12.dp)) { + Icon(NextcloudIcons.Error, contentDescription = null, tint = MaterialTheme.colorScheme.error) + Text(message, color = MaterialTheme.colorScheme.error) + onRetry?.let { retry -> OutlinedButton(onClick = retry) { Text("Try again") } } + } +} + +@Composable +internal fun SecureSessionStorageUnavailable( + onRetry: () -> Unit, + onSignInAgain: () -> Unit, +) { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Column( + modifier = Modifier.padding(NextcloudSpacing.XLarge), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Icon(NextcloudIcons.Error, contentDescription = null, tint = MaterialTheme.colorScheme.error) + Text( + "Secure session storage is locked or unavailable. Unlock it or allow " + + "Nextcloud Native access, then try again, or discard the stored session and sign in again.", + color = MaterialTheme.colorScheme.error, + ) + OutlinedButton(onClick = onRetry) { Text("Try again") } + OutlinedButton(onClick = onSignInAgain) { Text("Sign in again") } + } + } +} + +@Composable +internal fun LegacySessionMigrationUnavailable( + onRetry: () -> Unit, + onSignInAgain: () -> Unit, +) { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Column( + modifier = Modifier.padding(NextcloudSpacing.XLarge), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Icon(NextcloudIcons.Error, contentDescription = null, tint = MaterialTheme.colorScheme.error) + Text( + "The previous session needs the legacy secure-storage provider. Install the provider " + + "and try again, or discard the stored session and sign in again.", + color = MaterialTheme.colorScheme.error, + ) + OutlinedButton(onClick = onRetry) { Text("Try again") } + OutlinedButton(onClick = onSignInAgain) { Text("Sign in again") } + } + } +} diff --git a/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/NextcloudSessionLoadingTest.kt b/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/NextcloudSessionLoadingTest.kt new file mode 100644 index 000000000..d1f011789 --- /dev/null +++ b/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/NextcloudSessionLoadingTest.kt @@ -0,0 +1,53 @@ +package dev.obiente.nextcloudnative.app + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertIs +import kotlinx.coroutines.CancellationException + +class NextcloudSessionLoadingTest { + @Test + fun secureStorageFailureBecomesRetryableWithoutExposingItsMessage() { + var attempts = 0 + val expected = NextcloudSession("https://cloud.invalid", "alice", "synthetic-secret") + val load = { + attempts += 1 + if (attempts == 1) throw NextcloudSessionStorageUnavailableException("private provider failure") + expected + } + + assertEquals( + NextcloudSessionLoadState.SecureStorageUnavailable, + loadNextcloudSessionSafely(load), + ) + val recovered = assertIs(loadNextcloudSessionSafely(load)) + assertEquals(expected, recovered.session) + } + + @Test + fun cancellationRemainsControlFlow() { + assertFailsWith { + loadNextcloudSessionSafely { throw CancellationException("cancelled") } + } + } + + @Test + fun missingLegacyMigrationProviderKeepsItsRecoveryCategory() { + assertEquals( + NextcloudSessionLoadState.LegacyMigrationUnavailable, + loadNextcloudSessionSafely { + throw NextcloudSessionLegacyMigrationUnavailableException( + NextcloudSessionStorageUnavailableException("private provider failure"), + ) + }, + ) + } + + @Test + fun unrelatedProgrammingFailureIsNotPresentedAsUnavailableStorage() { + assertFailsWith { + loadNextcloudSessionSafely { error("synthetic invariant failure") } + } + } +} diff --git a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopDeckCardDraftStore.kt b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopDeckCardDraftStore.kt index 9732b0e3b..d39fb36e7 100644 --- a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopDeckCardDraftStore.kt +++ b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopDeckCardDraftStore.kt @@ -23,7 +23,9 @@ import org.json.JSONObject */ internal class DesktopDeckCardDraftStore( private val root: File = desktopDeckDraftDirectory(), - private val keyProvider: DesktopDeckDraftKeyProvider = PlatformDeckDraftKeyProvider(), + private val keyProvider: DesktopDeckDraftKeyProvider = PlatformDeckDraftKeyProvider( + legacySecretRequired = { desktopDeckLegacySecretRequired(root) }, + ), private val nowEpochMillis: () -> Long = System::currentTimeMillis, private val random: SecureRandom = SecureRandom(), ) { @@ -292,6 +294,16 @@ internal class DesktopDeckCardDraftStore( } } +internal fun desktopDeckLegacySecretRequired( + root: File, + listFiles: (File) -> Array? = File::listFiles, +): Boolean { + if (!root.exists()) return false + if (!root.isDirectory) return true + val entries = listFiles(root) ?: return true + return entries.any { file -> file.name.matches(DesktopDeckCardDraftStore.DRAFT_FILE_PATTERN) } +} + internal fun interface DesktopDeckDraftKeyProvider { fun encryptionKey(): ByteArray } @@ -299,6 +311,7 @@ internal fun interface DesktopDeckDraftKeyProvider { internal class PlatformDeckDraftKeyProvider( private val secretStore: DesktopSecretStore = defaultDesktopSecretStore(), private val random: SecureRandom = SecureRandom(), + private val legacySecretRequired: () -> Boolean = { true }, ) : DesktopDeckDraftKeyProvider { @Volatile private var cached: ByteArray? = null @@ -326,13 +339,31 @@ internal class PlatformDeckDraftKeyProvider( } private fun lookup(): ByteArray? { - val encoded = secretStore.load(desktopDeckDraftSecretReference()) + val stored = try { + secretStore.load(desktopDeckDraftSecretReference()) + } catch (failure: NextcloudSessionLegacyMigrationUnavailableException) { + if (legacySecretRequired()) throw failure + null + } catch (failure: DesktopSecretStoreUnavailableException) { + if (legacySecretRequired()) throw failure + null + } + val encoded = stored ?.let { value -> value.copyOf(minOf(value.size, MAX_ENCODED_KEY_BYTES)) } ?.decodeToString() ?.trim() - ?: return null - if (encoded.isBlank()) return null - return runCatching { Base64.getDecoder().decode(encoded) }.getOrNull() + ?: return missingKey() + if (encoded.isBlank()) return missingKey() + return runCatching { Base64.getDecoder().decode(encoded) }.getOrNull() ?: missingKey() + } + + private fun missingKey(): ByteArray? { + if (legacySecretRequired()) { + throw DesktopSecretStoreUnavailableException( + "The Deck draft encryption key is missing while encrypted drafts still exist.", + ) + } + return null } private companion object { diff --git a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopNextcloudServices.kt b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopNextcloudServices.kt index 84b37cd92..ba654ec47 100644 --- a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopNextcloudServices.kt +++ b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopNextcloudServices.kt @@ -1682,15 +1682,15 @@ class DesktopNextcloudServices( if (!isFileSyncPaused()) { runCatching { syncAllFileSyncPairs(DesktopFileSyncRunSource.Background) } } - val virtualFolderSession = loadSession() - runCatching { reconcileConfiguredVirtualFolders(virtualFolderSession) } - .onFailure { failure -> - publishFileSyncRunFailure( - virtualFolderSession?.let(::desktopFileCacheAccountId), - DesktopFileSyncRunSource.Background, - failure, - ) - } + reconcileDesktopBackgroundSession( + ::loadSession, ::reconcileConfiguredVirtualFolders, + ) { session, failure -> + publishFileSyncRunFailure( + session?.let(::desktopFileCacheAccountId), + DesktopFileSyncRunSource.Background, + failure, + ) + } delay(DESKTOP_FILE_SYNC_INTERVAL_MILLIS) } } @@ -3736,7 +3736,7 @@ class DesktopNextcloudServices( } var cleared = false try { - val accountId = loadSession()?.let(::desktopFileCacheAccountId) + val accountId = desktopStoredSessionAccountId(preferences) val syncJob = synchronized(this) { val active = backgroundFileSyncJob backgroundFileSyncJob = null @@ -3843,9 +3843,7 @@ class DesktopNextcloudServices( val server = preferences.get(KEY_SERVER, null) val login = preferences.get(KEY_LOGIN, null) runCatching { - if (server != null && login != null) { - secretStore.clear(desktopSessionSecretReference(server, login)) - } + if (server != null && login != null) secretStore.clear(desktopSessionSecretReference(server, login)) }.onFailure { failure -> supportDiagnostics.record( SupportDiagnosticEventDraft( @@ -3856,6 +3854,8 @@ class DesktopNextcloudServices( exception = failure.toSupportDiagnosticExceptionDraft(), ), ) + if (failure is DesktopSecretDeletionRecoveryUnavailableException || + failure is DesktopSecretLegacyCleanupUnavailableException) throw failure } sessionPublicationGuard.serialize { preferences.remove(KEY_SERVER) @@ -3867,7 +3867,7 @@ class DesktopNextcloudServices( } finally { if (!cleared) { synchronized(fileRangeSessionLock) { sessionClearing = false } - if (loadSession() != null) startDesktopSyncLifecycle() + if (desktopStoredSessionAccountId(preferences) != null) startDesktopSyncLifecycle() } } } diff --git a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopSecretStore.kt b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopSecretStore.kt index cc3b7cb0e..90abb2b02 100644 --- a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopSecretStore.kt +++ b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopSecretStore.kt @@ -5,6 +5,7 @@ import com.sun.jna.Native import com.sun.jna.Pointer import com.sun.jna.Structure import com.sun.jna.WString +import com.sun.jna.ptr.IntByReference import com.sun.jna.ptr.PointerByReference import com.sun.jna.win32.StdCallLibrary import com.sun.jna.win32.W32APIOptions @@ -12,6 +13,7 @@ import java.security.MessageDigest import java.util.concurrent.Executors import java.util.concurrent.TimeUnit import java.util.concurrent.TimeoutException +import java.util.prefs.Preferences internal data class DesktopSecretReference( val targetName: String, @@ -47,28 +49,203 @@ internal interface DesktopSecretStore { internal class DesktopSecretStoreUnavailableException( message: String, + val reason: DesktopSecretStoreUnavailableReason = DesktopSecretStoreUnavailableReason.StorageLockedOrUnavailable, cause: Throwable? = null, -) : IllegalStateException(message, cause) +) : NextcloudSessionStorageUnavailableException(message, cause) + +internal class DesktopSecretDeletionRecoveryUnavailableException( + cause: Throwable, +) : NextcloudSessionStorageUnavailableException( + "Secure credential cleanup could not be scheduled for retry.", + cause, +) + +internal class DesktopSecretLegacyCleanupUnavailableException( + cause: Throwable, +) : NextcloudSessionStorageUnavailableException( + "The legacy secure credential could not be cleared safely.", + cause, +) + +internal enum class DesktopSecretStoreUnavailableReason { + StorageLockedOrUnavailable, + ProviderMissing, +} internal enum class DesktopSecretStoreKind { + MacOsKeychain, SecretService, WindowsCredentialManager, } internal fun desktopSecretStoreKind(osName: String = System.getProperty("os.name", "")): DesktopSecretStoreKind = - if (osName.startsWith("Windows", ignoreCase = true)) { - DesktopSecretStoreKind.WindowsCredentialManager - } else { - DesktopSecretStoreKind.SecretService + when { + osName.startsWith("Windows", ignoreCase = true) -> DesktopSecretStoreKind.WindowsCredentialManager + osName.startsWith("Mac", ignoreCase = true) -> DesktopSecretStoreKind.MacOsKeychain + else -> DesktopSecretStoreKind.SecretService } internal fun defaultDesktopSecretStore( osName: String = System.getProperty("os.name", ""), ): DesktopSecretStore = when (desktopSecretStoreKind(osName)) { + DesktopSecretStoreKind.MacOsKeychain -> MigratingDesktopSecretStore( + primary = MacOsKeychainSecretStore(), + legacy = SecretToolDesktopSecretStore(), + adoption = PreferencesDesktopSecretStoreAdoption(), + ) DesktopSecretStoreKind.SecretService -> SecretToolDesktopSecretStore() DesktopSecretStoreKind.WindowsCredentialManager -> WindowsCredentialManagerSecretStore() } +internal interface DesktopSecretStoreAdoption { + fun state(reference: DesktopSecretReference): DesktopSecretStoreAdoptionState + + fun markAdopted(reference: DesktopSecretReference) + + fun markLegacyCleanupComplete(reference: DesktopSecretReference) +} + +internal enum class DesktopSecretStoreAdoptionState { + NotAdopted, + AdoptedPendingLegacyCleanup, + AdoptedAndClean, +} + +internal class MigratingDesktopSecretStore( + private val primary: DesktopSecretStore, + private val legacy: DesktopSecretStore, + private val adoption: DesktopSecretStoreAdoption, +) : DesktopSecretStore { + override fun load(reference: DesktopSecretReference): ByteArray? { + primary.load(reference)?.let { secret -> + adoptAndRetryLegacyCleanupBestEffort(reference) + return secret + } + if (adoption.state(reference) != DesktopSecretStoreAdoptionState.NotAdopted) { + retryLegacyCleanup(reference) + return null + } + val secret = try { + legacy.load(reference) + } catch (failure: DesktopSecretStoreUnavailableException) { + if (failure.reason == DesktopSecretStoreUnavailableReason.ProviderMissing) { + throw NextcloudSessionLegacyMigrationUnavailableException(failure) + } + throw failure + } ?: return null + primary.save(reference, username = null, secret = secret) + adoptAndRetryLegacyCleanupBestEffort(reference) + return secret + } + + override fun save(reference: DesktopSecretReference, username: String?, secret: ByteArray) { + primary.save(reference, username, secret) + adoptAndRetryLegacyCleanupBestEffort(reference) + } + + override fun clear(reference: DesktopSecretReference) { + val legacyCleanupQueued = markAdopted(reference) + val primaryFailure = try { + primary.clear(reference) + null + } catch (failure: kotlinx.coroutines.CancellationException) { + throw failure + } catch (failure: Exception) { + failure + } + retryLegacyCleanup(reference)?.let { failure -> + primaryFailure?.let(failure::addSuppressed) + if (primaryFailure != null || !legacyCleanupQueued) { + throw DesktopSecretLegacyCleanupUnavailableException(failure) + } + } + primaryFailure?.let { throw it } + } + + private fun adoptAndRetryLegacyCleanupBestEffort(reference: DesktopSecretReference) { + val adoptionDurable = markAdopted(reference) + val legacyCleanupFailure = retryLegacyCleanup(reference) + if (!adoptionDurable && legacyCleanupFailure != null) { + throw DesktopSecretStoreUnavailableException( + "Keychain adoption and legacy credential cleanup are both unavailable.", + cause = legacyCleanupFailure, + ) + } + } + + private fun markAdopted(reference: DesktopSecretReference): Boolean = + try { + if (adoption.state(reference) == DesktopSecretStoreAdoptionState.NotAdopted) { + adoption.markAdopted(reference) + } + true + } catch (failure: kotlinx.coroutines.CancellationException) { + throw failure + } catch (_: Exception) { + false + } + + private fun retryLegacyCleanup(reference: DesktopSecretReference): Exception? { + val alreadyClean = try { + adoption.state(reference) == DesktopSecretStoreAdoptionState.AdoptedAndClean + } catch (failure: kotlinx.coroutines.CancellationException) { + throw failure + } catch (_: Exception) { + false + } + if (alreadyClean) return null + try { + legacy.clear(reference) + } catch (failure: kotlinx.coroutines.CancellationException) { + throw failure + } catch (failure: Exception) { + return failure + } + try { + adoption.markLegacyCleanupComplete(reference) + } catch (failure: kotlinx.coroutines.CancellationException) { + throw failure + } catch (_: Exception) { + // Legacy cleanup is already complete; only the optional durable marker is unavailable. + } + return null + } +} + +private class PreferencesDesktopSecretStoreAdoption( + private val preferences: Preferences = Preferences.userRoot() + .node("dev/obiente/nextcloudnative/secret-store-adoption-v1"), +) : DesktopSecretStoreAdoption { + override fun state(reference: DesktopSecretReference): DesktopSecretStoreAdoptionState = + when (preferences.get(reference.adoptionKey(), null)) { + ADOPTED_AND_CLEAN -> DesktopSecretStoreAdoptionState.AdoptedAndClean + ADOPTED_PENDING_CLEANUP, LEGACY_ADOPTED_VALUE -> + DesktopSecretStoreAdoptionState.AdoptedPendingLegacyCleanup + else -> DesktopSecretStoreAdoptionState.NotAdopted + } + + override fun markAdopted(reference: DesktopSecretReference) { + preferences.put(reference.adoptionKey(), ADOPTED_PENDING_CLEANUP) + preferences.flush() + } + + override fun markLegacyCleanupComplete(reference: DesktopSecretReference) { + check(state(reference) != DesktopSecretStoreAdoptionState.NotAdopted) + preferences.put(reference.adoptionKey(), ADOPTED_AND_CLEAN) + preferences.flush() + } + + private fun DesktopSecretReference.adoptionKey(): String = MessageDigest.getInstance("SHA-256") + .digest(targetName.encodeToByteArray()) + .toHexString() + + private companion object { + const val LEGACY_ADOPTED_VALUE = "true" + const val ADOPTED_PENDING_CLEANUP = "adopted-pending-legacy-cleanup" + const val ADOPTED_AND_CLEAN = "adopted-and-clean" + } +} + internal fun desktopSessionSecretReference(serverUrl: String, loginName: String): DesktopSecretReference { require(serverUrl.isNotBlank() && loginName.isNotBlank()) val identity = MessageDigest.getInstance("SHA-256") @@ -110,7 +287,13 @@ internal class SecretToolDesktopSecretStore( override fun load(reference: DesktopSecretReference): ByteArray? { val process = runCatching { startProcess(secretToolCommand("lookup", reference)) - }.getOrElse { return null } + }.getOrElse { failure -> + throw DesktopSecretStoreUnavailableException( + MISSING_SECRET_TOOL_MESSAGE, + DesktopSecretStoreUnavailableReason.ProviderMissing, + failure, + ) + } val executor = Executors.newSingleThreadExecutor { runnable -> Thread(runnable, "nextcloud-native-secret-reader").apply { isDaemon = true } } @@ -126,17 +309,21 @@ internal class SecretToolDesktopSecretStore( val elapsedMillis = TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - startedAt) val remainingMillis = (timeoutMillis - elapsedMillis).coerceAtLeast(1L) val bytes = output.get(remainingMillis, TimeUnit.MILLISECONDS) - if (process.exitValue() != 0 || bytes.isEmpty()) return null + if (process.exitValue() != 0) { + if (!hasMatchingSecret(reference)) return null + throw DesktopSecretStoreUnavailableException(KEYRING_UNAVAILABLE_MESSAGE) + } + if (bytes.isEmpty()) return null check(bytes.size <= MAX_SECRET_BYTES) { "The desktop secret service returned an oversized value." } return bytes.trimSingleTrailingLineBreak() - } catch (_: TimeoutException) { + } catch (failure: TimeoutException) { timedOut = true runCatching { process.descendants().forEach { child -> runCatching { child.destroyForcibly() } } } process.destroyForcibly() output.cancel(true) - error("Timed out while loading a desktop secret.") + throw DesktopSecretStoreUnavailableException(KEYRING_UNAVAILABLE_MESSAGE, cause = failure) } finally { if (!timedOut) runCatching { process.inputStream.close() } executor.shutdownNow() @@ -155,13 +342,17 @@ internal class SecretToolDesktopSecretStore( } } val process = runCatching { startProcess(command) }.getOrElse { failure -> - throw DesktopSecretStoreUnavailableException(MISSING_SECRET_TOOL_MESSAGE, failure) + throw DesktopSecretStoreUnavailableException( + MISSING_SECRET_TOOL_MESSAGE, + DesktopSecretStoreUnavailableReason.ProviderMissing, + failure, + ) } runCatching { process.outputStream.use { it.write(secret) } }.getOrElse { failure -> process.destroyForcibly() - throw DesktopSecretStoreUnavailableException(KEYRING_UNAVAILABLE_MESSAGE, failure) + throw DesktopSecretStoreUnavailableException(KEYRING_UNAVAILABLE_MESSAGE, cause = failure) } if (!process.waitFor(timeoutMillis, TimeUnit.MILLISECONDS)) { process.destroyForcibly() @@ -172,11 +363,67 @@ internal class SecretToolDesktopSecretStore( } } + private fun hasMatchingSecret(reference: DesktopSecretReference): Boolean { + val command = buildList { + add("secret-tool") + add("search") + add("--all") + add("--unlock") + reference.attributes.forEach { (key, value) -> + add(key) + add(value) + } + } + val process = runCatching { startProcess(command) }.getOrElse { failure -> + throw DesktopSecretStoreUnavailableException( + MISSING_SECRET_TOOL_MESSAGE, + DesktopSecretStoreUnavailableReason.ProviderMissing, + failure, + ) + } + val executor = Executors.newSingleThreadExecutor { runnable -> + Thread(runnable, "nextcloud-native-secret-search").apply { isDaemon = true } + } + val output = executor.submit { + process.inputStream.use { it.readNBytes(MAX_SECRET_SEARCH_BYTES + 1) } + } + var timedOut = false + try { + if (!process.waitFor(timeoutMillis, TimeUnit.MILLISECONDS)) throw TimeoutException() + val bytes = output.get(timeoutMillis, TimeUnit.MILLISECONDS) + if (process.exitValue() != 0 || bytes.size > MAX_SECRET_SEARCH_BYTES) { + throw DesktopSecretStoreUnavailableException(KEYRING_UNAVAILABLE_MESSAGE) + } + return bytes.isNotEmpty() + } catch (failure: TimeoutException) { + timedOut = true + process.destroyForcibly() + output.cancel(true) + throw DesktopSecretStoreUnavailableException(KEYRING_UNAVAILABLE_MESSAGE, cause = failure) + } finally { + if (!timedOut) runCatching { process.inputStream.close() } + executor.shutdownNow() + } + } + override fun clear(reference: DesktopSecretReference) { val process = runCatching { startProcess(secretToolCommand("clear", reference)) - }.getOrElse { return } - if (!process.waitFor(timeoutMillis, TimeUnit.MILLISECONDS)) process.destroyForcibly() + }.getOrElse { failure -> + throw DesktopSecretStoreUnavailableException( + MISSING_SECRET_TOOL_MESSAGE, + DesktopSecretStoreUnavailableReason.ProviderMissing, + failure, + ) + } + if (!process.waitFor(timeoutMillis, TimeUnit.MILLISECONDS)) { + process.destroyForcibly() + throw DesktopSecretStoreUnavailableException(KEYRING_UNAVAILABLE_MESSAGE) + } + if (process.exitValue() != 0) { + if (!hasMatchingSecret(reference)) return + throw DesktopSecretStoreUnavailableException(KEYRING_UNAVAILABLE_MESSAGE) + } } private fun secretToolCommand(command: String, reference: DesktopSecretReference): List = buildList { @@ -189,6 +436,221 @@ internal class SecretToolDesktopSecretStore( } } +internal class MacOsKeychainSecretStore( + private val api: MacOsKeychainApi = MacOsKeychainApiHolder.instance, + private val releaseItem: (Pointer) -> Unit = MacOsCoreFoundationApiHolder::release, + private val deletionRecovery: MacOsKeychainDeletionRecovery = PreferencesMacOsKeychainDeletionRecovery(), +) : DesktopSecretStore { + private val deletionCoordinator = MacOsKeychainDeletionCoordinator(deletionRecovery, ::deleteTarget) + + init { + deletionCoordinator.retryAllBestEffort() + } + + override fun load(reference: DesktopSecretReference): ByteArray? { + deletionCoordinator.retry(reference.targetName) + val secretLength = IntByReference() + val secretData = PointerByReference() + val item = PointerByReference() + val identity = reference.macOsIdentity() + val status = api.SecKeychainFindGenericPassword( + null, + identity.service.size, + identity.service, + identity.account.size, + identity.account, + secretLength, + secretData, + item, + ) + if (status == ERR_SEC_ITEM_NOT_FOUND) return null + checkMacOsKeychainStatus(status, "load") + val size = secretLength.value + val data = secretData.value + val itemPointer = item.value + try { + if (size !in 1..MAX_SECRET_BYTES || data == null) { + clear(reference) + return null + } + return data.getByteArray(0, size) + } finally { + if (data != null) api.SecKeychainItemFreeContent(null, data) + if (itemPointer != null) releaseItem(itemPointer) + } + } + + override fun save(reference: DesktopSecretReference, username: String?, secret: ByteArray) { + require(secret.isNotEmpty() && secret.size <= MAX_SECRET_BYTES) + deletionCoordinator.retry(reference.targetName) + val identity = reference.macOsIdentity() + val item = PointerByReference() + val findStatus = api.SecKeychainFindGenericPassword( + null, + identity.service.size, + identity.service, + identity.account.size, + identity.account, + null, + null, + item, + ) + when (findStatus) { + ERR_SEC_ITEM_NOT_FOUND -> add(identity, secret) + ERR_SEC_SUCCESS -> update(checkNotNull(item.value), secret) + else -> checkMacOsKeychainStatus(findStatus, "find before save") + } + } + + override fun clear(reference: DesktopSecretReference) { + deletionCoordinator.clear(reference.targetName) + } + + private fun deleteTarget(targetName: String) { + val identity = targetName.macOsIdentity() + val item = PointerByReference() + val status = api.SecKeychainFindGenericPassword( + null, + identity.service.size, + identity.service, + identity.account.size, + identity.account, + null, + null, + item, + ) + if (status == ERR_SEC_ITEM_NOT_FOUND) return + checkMacOsKeychainStatus(status, "find before clear") + val itemPointer = checkNotNull(item.value) { "macOS Keychain returned an empty item." } + try { + checkMacOsKeychainStatus(api.SecKeychainItemDelete(itemPointer), "clear") + } finally { + releaseItem(itemPointer) + } + } + + private fun add(identity: MacOsKeychainIdentity, secret: ByteArray) { + val status = api.SecKeychainAddGenericPassword( + null, + identity.service.size, + identity.service, + identity.account.size, + identity.account, + secret.size, + secret, + null, + ) + if (status != ERR_SEC_DUPLICATE_ITEM) { + checkMacOsKeychainStatus(status, "save") + return + } + val item = PointerByReference() + checkMacOsKeychainStatus( + api.SecKeychainFindGenericPassword( + null, + identity.service.size, + identity.service, + identity.account.size, + identity.account, + null, + null, + item, + ), + "find after concurrent save", + ) + update(checkNotNull(item.value), secret) + } + + private fun update(item: Pointer, secret: ByteArray) { + try { + checkMacOsKeychainStatus( + api.SecKeychainItemModifyAttributesAndData(item, null, secret.size, secret), + "update", + ) + } finally { + releaseItem(item) + } + } +} + +internal interface MacOsKeychainApi : com.sun.jna.Library { + fun SecKeychainFindGenericPassword( + keychainOrArray: Pointer?, + serviceNameLength: Int, + serviceName: ByteArray, + accountNameLength: Int, + accountName: ByteArray, + secretLength: IntByReference?, + secretData: PointerByReference?, + itemRef: PointerByReference, + ): Int + + fun SecKeychainAddGenericPassword( + keychain: Pointer?, + serviceNameLength: Int, + serviceName: ByteArray, + accountNameLength: Int, + accountName: ByteArray, + secretLength: Int, + secretData: ByteArray, + itemRef: PointerByReference?, + ): Int + + fun SecKeychainItemModifyAttributesAndData( + itemRef: Pointer, + attributes: Pointer?, + secretLength: Int, + secretData: ByteArray, + ): Int + + fun SecKeychainItemDelete(itemRef: Pointer): Int + + fun SecKeychainItemFreeContent(attributes: Pointer?, secretData: Pointer?): Int +} + +private data class MacOsKeychainIdentity( + val service: ByteArray, + val account: ByteArray, +) + +private fun DesktopSecretReference.macOsIdentity(): MacOsKeychainIdentity = targetName.macOsIdentity() + +private fun String.macOsIdentity(): MacOsKeychainIdentity = MacOsKeychainIdentity( + service = encodeToByteArray(), + account = MessageDigest.getInstance("SHA-256") + .digest(encodeToByteArray()) + .toHexString() + .encodeToByteArray(), +) + +private fun checkMacOsKeychainStatus(status: Int, operation: String) { + if (status == ERR_SEC_SUCCESS) return + val reason = when (status) { + ERR_SEC_AUTH_FAILED -> "Keychain access was denied." + ERR_SEC_INTERACTION_NOT_ALLOWED -> "The login Keychain is locked or unavailable." + else -> "macOS Keychain failed to $operation the desktop secret (error $status)." + } + throw DesktopSecretStoreUnavailableException(reason) +} + +private object MacOsKeychainApiHolder { + val instance: MacOsKeychainApi by lazy { + Native.load(MACOS_SECURITY_FRAMEWORK, MacOsKeychainApi::class.java) + } +} + +private object MacOsCoreFoundationApiHolder { + private val api: MacOsCoreFoundationApi by lazy { + Native.load(MACOS_CORE_FOUNDATION_FRAMEWORK, MacOsCoreFoundationApi::class.java) + } + + fun release(pointer: Pointer) = api.CFRelease(pointer) +} + +private interface MacOsCoreFoundationApi : com.sun.jna.Library { + fun CFRelease(pointer: Pointer) +} + internal class WindowsCredentialManagerSecretStore( private val api: WindowsCredentialApi = WindowsCredentialApiHolder.instance, ) : DesktopSecretStore { @@ -318,8 +780,17 @@ private const val WINDOWS_CREDENTIAL_PREFIX = "Obiente/NextcloudNative" private const val CRED_TYPE_GENERIC = 1 private const val CRED_PERSIST_LOCAL_MACHINE = 2 private const val ERROR_NOT_FOUND = 1_168 +private const val ERR_SEC_SUCCESS = 0 +private const val ERR_SEC_AUTH_FAILED = -25_293 +private const val ERR_SEC_DUPLICATE_ITEM = -25_299 +private const val ERR_SEC_ITEM_NOT_FOUND = -25_300 +private const val ERR_SEC_INTERACTION_NOT_ALLOWED = -25_308 +private const val MACOS_SECURITY_FRAMEWORK = "/System/Library/Frameworks/Security.framework/Security" +private const val MACOS_CORE_FOUNDATION_FRAMEWORK = + "/System/Library/Frameworks/CoreFoundation.framework/CoreFoundation" private const val MAX_SECRET_BYTES = 2_560 +private const val MAX_SECRET_SEARCH_BYTES = 256 * 1024 private const val MISSING_SECRET_TOOL_MESSAGE = "Secure credential storage is unavailable. Install libsecret-tools on Debian or Ubuntu, or libsecret on Fedora or RHEL, then restart Nextcloud Native." private const val KEYRING_UNAVAILABLE_MESSAGE = - "Could not save the account securely. Make sure your desktop keyring is running and unlocked, then try again." + "Secure credential storage is unavailable. Make sure your desktop keyring is running and unlocked, then try again." diff --git a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopSyncLifecycleRecovery.kt b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopSyncLifecycleRecovery.kt new file mode 100644 index 000000000..0347d1028 --- /dev/null +++ b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopSyncLifecycleRecovery.kt @@ -0,0 +1,32 @@ +package dev.obiente.nextcloudnative.app + +import kotlinx.coroutines.CancellationException +import java.util.prefs.Preferences + +internal fun desktopStoredSessionAccountId(preferences: Preferences): String? = + preferences.get("server", null)?.let { server -> + preferences.get("login", null)?.let { login -> + desktopFileCacheAccountId(NextcloudSession(server, login, "unused")) + } + } + +internal suspend fun reconcileDesktopBackgroundSession( + loadSession: () -> NextcloudSession?, + reconcile: suspend (NextcloudSession?) -> Unit, + onFailure: (NextcloudSession?, Throwable) -> Unit = { _, _ -> }, +): Boolean { + val loaded = loadNextcloudSessionSafely(loadSession) + val session = when (loaded) { + is NextcloudSessionLoadState.Loaded -> loaded.session + NextcloudSessionLoadState.SecureStorageUnavailable, + NextcloudSessionLoadState.LegacyMigrationUnavailable -> return false + } + try { + reconcile(session) + } catch (failure: CancellationException) { + throw failure + } catch (failure: Throwable) { + onFailure(session, failure) + } + return true +} diff --git a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/MacOsKeychainDeletionRecovery.kt b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/MacOsKeychainDeletionRecovery.kt new file mode 100644 index 000000000..1615c34c0 --- /dev/null +++ b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/MacOsKeychainDeletionRecovery.kt @@ -0,0 +1,142 @@ +package dev.obiente.nextcloudnative.app + +import java.security.MessageDigest +import java.util.prefs.Preferences + +internal interface MacOsKeychainDeletionRecovery { + fun pendingTargetNames(): Set + + fun markPending(targetName: String) + + fun markComplete(targetName: String) +} + +internal class MacOsKeychainDeletionCoordinator( + private val recovery: MacOsKeychainDeletionRecovery, + private val deleteTarget: (String) -> Unit, +) { + fun clear(targetName: String) { + try { + recovery.markPending(targetName) + } catch (failure: kotlinx.coroutines.CancellationException) { + throw failure + } catch (failure: Exception) { + throw DesktopSecretDeletionRecoveryUnavailableException(failure) + } + deleteTarget(targetName) + markComplete(targetName) + } + + fun retryAllBestEffort() { + val targetNames = try { + recovery.pendingTargetNames() + } catch (failure: kotlinx.coroutines.CancellationException) { + throw failure + } catch (_: Exception) { + return + } + targetNames.forEach { targetName -> + try { + deleteTarget(targetName) + markComplete(targetName) + } catch (failure: kotlinx.coroutines.CancellationException) { + throw failure + } catch (_: Exception) { + // The durable record remains pending for the next operation or process start. + } + } + } + + fun retry(targetName: String) { + val pending = try { + targetName in recovery.pendingTargetNames() + } catch (failure: kotlinx.coroutines.CancellationException) { + throw failure + } catch (failure: Exception) { + throw DesktopSecretStoreUnavailableException( + "macOS Keychain cleanup recovery is unavailable.", + cause = failure, + ) + } + if (!pending) return + deleteTarget(targetName) + markComplete(targetName) + } + + private fun markComplete(targetName: String) { + try { + recovery.markComplete(targetName) + } catch (failure: kotlinx.coroutines.CancellationException) { + throw failure + } catch (failure: Exception) { + throw DesktopSecretStoreUnavailableException( + "macOS Keychain cleanup could not record completion.", + cause = failure, + ) + } + } +} + +internal class PreferencesMacOsKeychainDeletionRecovery( + private val preferences: Preferences = Preferences.userRoot() + .node("dev/obiente/nextcloudnative/macos-keychain-deletion-v1"), + private val flush: (Preferences) -> Unit = { it.flush() }, +) : MacOsKeychainDeletionRecovery { + override fun pendingTargetNames(): Set = preferences.keys() + .mapNotNullTo(linkedSetOf()) { key -> + preferences.get(key, null) + ?.takeIf { value -> value.startsWith(PENDING_PREFIX) } + ?.removePrefix(PENDING_PREFIX) + ?.takeIf(::isValidTargetName) + } + + override fun markPending(targetName: String) { + require(isValidTargetName(targetName)) + val key = targetName.recoveryKey() + val previous = preferences.get(key, null) + val existingEntries = pendingTargetNames().size + check(previous != null || existingEntries < MAX_RECOVERY_ENTRIES) { + "Too many pending macOS Keychain cleanup records." + } + preferences.put(key, PENDING_PREFIX + targetName) + try { + flush(preferences) + } catch (failure: Exception) { + if (previous == null) preferences.remove(key) else preferences.put(key, previous) + throw failure + } + } + + override fun markComplete(targetName: String) { + require(isValidTargetName(targetName)) + val key = targetName.recoveryKey() + preferences.put(key, COMPLETE_PREFIX + targetName) + try { + flush(preferences) + } catch (failure: Exception) { + // Keep this process conservative when durable completion is ambiguous. + preferences.put(key, PENDING_PREFIX + targetName) + throw failure + } + preferences.remove(key) + runCatching { flush(preferences) } + } + + private fun String.recoveryKey(): String = MessageDigest.getInstance("SHA-256") + .digest(encodeToByteArray()) + .joinToString(separator = "") { byte -> + (byte.toInt() and 0xff).toString(16).padStart(2, '0') + } + + private fun isValidTargetName(targetName: String): Boolean = + targetName.isNotBlank() && + targetName.length <= MAX_TARGET_NAME_CHARACTERS && + targetName.none(Char::isISOControl) + + private companion object { + const val PENDING_PREFIX = "pending:" + const val COMPLETE_PREFIX = "complete:" + const val MAX_TARGET_NAME_CHARACTERS = 512 + const val MAX_RECOVERY_ENTRIES = 128 + } +} diff --git a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopDeckCardDraftStoreTest.kt b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopDeckCardDraftStoreTest.kt index 083ae03de..35cfa9e19 100644 --- a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopDeckCardDraftStoreTest.kt +++ b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopDeckCardDraftStoreTest.kt @@ -115,6 +115,46 @@ class DesktopDeckCardDraftStoreTest { assertTrue(file.isFile) } + @Test + fun `missing key does not replace or delete an existing encrypted draft`() = + withStore { root, _, store -> + val session = session() + val persisted = persisted() + store.save(session, persisted) + val file = root.resolve(store.storageFileName(session, persisted.key)) + val missingSecrets = object : DesktopSecretStore { + override fun load(reference: DesktopSecretReference): ByteArray? = null + + override fun save(reference: DesktopSecretReference, username: String?, secret: ByteArray) { + error("A replacement key must not be saved.") + } + + override fun clear(reference: DesktopSecretReference) = Unit + } + val unavailable = DesktopDeckCardDraftStore( + root = root, + keyProvider = PlatformDeckDraftKeyProvider( + secretStore = missingSecrets, + legacySecretRequired = { desktopDeckLegacySecretRequired(root) }, + ), + ) + + assertFailsWith { + unavailable.load(session, persisted.key) + } + assertTrue(file.isFile) + } + + @Test + fun `uninspectable draft directory conservatively requires the legacy secret`() { + val root = Files.createTempDirectory("desktop-deck-drafts-unreadable").toFile() + try { + assertTrue(desktopDeckLegacySecretRequired(root, listFiles = { null })) + } finally { + root.deleteRecursively() + } + } + @Test fun `clear removes only the requested account resource`() = withStore { root, _, store -> diff --git a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopSecretStoreTest.kt b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopSecretStoreTest.kt index 66a4c35bf..cc5a75f43 100644 --- a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopSecretStoreTest.kt +++ b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopSecretStoreTest.kt @@ -1,5 +1,9 @@ package dev.obiente.nextcloudnative.app +import com.sun.jna.Memory +import com.sun.jna.Pointer +import com.sun.jna.ptr.IntByReference +import com.sun.jna.ptr.PointerByReference import java.io.ByteArrayInputStream import java.io.ByteArrayOutputStream import java.io.InputStream @@ -7,6 +11,8 @@ import java.io.OutputStream import java.util.UUID import java.util.concurrent.CountDownLatch import java.util.concurrent.TimeUnit +import java.util.prefs.BackingStoreException +import java.util.prefs.Preferences import kotlin.test.Test import kotlin.test.assertContentEquals import kotlin.test.assertEquals @@ -35,6 +41,7 @@ class DesktopSecretStoreTest { assertTrue(failure.message.orEmpty().contains("libsecret-tools")) assertTrue(failure.message.orEmpty().contains("libsecret")) + assertEquals(DesktopSecretStoreUnavailableReason.ProviderMissing, failure.reason) assertFalse(failure.message.orEmpty().contains("Cannot run program")) assertFalse(failure.message.orEmpty().contains("synthetic-user")) assertFalse(failure.message.orEmpty().contains("synthetic-secret")) @@ -63,7 +70,7 @@ class DesktopSecretStoreTest { } @Test - fun secretLookupTimeoutIncludesReadingStandardOutput() { + fun secretLookupTimeoutIsReportedAsUnavailableSecureStorage() { val store = SecretToolDesktopSecretStore( timeoutMillis = 100, startProcess = { NeverCompletingProcess() }, @@ -75,12 +82,83 @@ class DesktopSecretStoreTest { ) val startedAt = System.nanoTime() - val failure = assertFailsWith { store.load(reference) } + val failure = assertFailsWith { store.load(reference) } - assertTrue(failure.message.orEmpty().contains("Timed out")) + assertTrue(failure.message.orEmpty().contains("running and unlocked")) assertTrue(System.nanoTime() - startedAt < 1_000_000_000L) } + @Test + fun failedSecretLookupCannotBeMistakenForConfirmedAbsenceDuringMigration() { + val reference = desktopDeckDraftSecretReference() + val legacy = SecretToolDesktopSecretStore( + startProcess = { throw java.io.IOException("synthetic missing executable") }, + ) + val primary = RecordingSecretStore() + val adoption = RecordingSecretStoreAdoption() + + val failure = assertFailsWith { + MigratingDesktopSecretStore(primary, legacy, adoption).load(reference) + } + + assertTrue(failure.message.orEmpty().contains("legacy secure-storage provider")) + assertNull(primary.load(reference)) + assertEquals(DesktopSecretStoreAdoptionState.NotAdopted, adoption.state(reference)) + } + + @Test + fun rejectedSecretLookupCannotBeMistakenForConfirmedAbsenceDuringMigration() { + val reference = desktopDeckDraftSecretReference() + val legacy = SecretToolDesktopSecretStore( + startProcess = { CompletedProcess(exitCode = 1) }, + ) + + assertFailsWith { + MigratingDesktopSecretStore( + RecordingSecretStore(), + legacy, + RecordingSecretStoreAdoption(), + ).load(reference) + } + } + + @Test + fun emptyUnlockedSearchConfirmsThatNoLegacySecretExists() { + val reference = desktopDeckDraftSecretReference() + val legacy = SecretToolDesktopSecretStore( + startProcess = { command -> + CompletedProcess(exitCode = if (command[1] == "search") 0 else 1) + }, + ) + val adoption = RecordingSecretStoreAdoption() + + assertNull(MigratingDesktopSecretStore(RecordingSecretStore(), legacy, adoption).load(reference)) + + assertEquals(DesktopSecretStoreAdoptionState.NotAdopted, adoption.state(reference)) + } + + @Test + fun failedLegacyClearIsReportedUnlessSearchConfirmsTheItemIsGone() { + val reference = desktopDeckDraftSecretReference() + val stillPresent = SecretToolDesktopSecretStore( + startProcess = { command -> + if (command[1] == "search") { + CompletedProcess(0, "synthetic matching item".encodeToByteArray()) + } else { + CompletedProcess(1) + } + }, + ) + val absent = SecretToolDesktopSecretStore( + startProcess = { command -> + CompletedProcess(exitCode = if (command[1] == "search") 0 else 1) + }, + ) + + assertFailsWith { stillPresent.clear(reference) } + absent.clear(reference) + } + private class NeverCompletingProcess : Process() { private val completion = CountDownLatch(1) private val output = ByteArrayOutputStream() @@ -108,11 +186,14 @@ class DesktopSecretStoreTest { override fun isAlive(): Boolean = completion.count > 0L } - private class CompletedProcess(private val exitCode: Int) : Process() { + private class CompletedProcess( + private val exitCode: Int, + private val input: ByteArray = ByteArray(0), + ) : Process() { private val output = ByteArrayOutputStream() override fun getOutputStream(): OutputStream = output - override fun getInputStream(): InputStream = ByteArrayInputStream(ByteArray(0)) + override fun getInputStream(): InputStream = ByteArrayInputStream(input) override fun getErrorStream(): InputStream = ByteArrayInputStream(ByteArray(0)) override fun waitFor(): Int = exitCode override fun waitFor(timeout: Long, unit: TimeUnit): Boolean = true @@ -122,13 +203,629 @@ class DesktopSecretStoreTest { } @Test - fun platformSelectionUsesWindowsCredentialManagerOnlyOnWindows() { + fun platformSelectionUsesEachNativeCredentialStore() { assertEquals( DesktopSecretStoreKind.WindowsCredentialManager, desktopSecretStoreKind("Windows 11"), ) assertEquals(DesktopSecretStoreKind.SecretService, desktopSecretStoreKind("Linux")) - assertEquals(DesktopSecretStoreKind.SecretService, desktopSecretStoreKind("Mac OS X")) + assertEquals(DesktopSecretStoreKind.MacOsKeychain, desktopSecretStoreKind("Mac OS X")) + } + + @Test + fun macOsKeychainAddsUpdatesLoadsAndClearsWithoutPuttingSecretsInIdentityFields() { + val api = FakeMacOsKeychainApi() + val releasedItems = mutableListOf() + val store = MacOsKeychainSecretStore( + api, + releasedItems::add, + RecordingMacOsKeychainDeletionRecovery(), + ) + val reference = desktopSessionSecretReference("https://cloud.invalid", "alice") + val first = "first-synthetic-secret".encodeToByteArray() + val second = "second-synthetic-secret".encodeToByteArray() + + assertNull(store.load(reference)) + store.save(reference, "alice", first) + assertContentEquals(first, store.load(reference)) + store.save(reference, "alice", second) + assertContentEquals(second, store.load(reference)) + store.clear(reference) + assertNull(store.load(reference)) + + assertEquals(reference.targetName, api.lastService) + assertEquals(64, api.lastAccount?.length) + assertFalse(api.lastAccount.orEmpty().contains("alice")) + assertFalse(api.lastService.orEmpty().contains("cloud.invalid")) + assertFalse(api.lastService.orEmpty().contains("alice")) + assertTrue(releasedItems.isNotEmpty()) + } + + @Test + fun existingSecretServiceSessionsAndDraftKeysMigrateBeforeKeychainAdoption() { + val sessionReference = desktopSessionSecretReference("https://cloud.invalid", "alice") + val draftReference = desktopDeckDraftSecretReference() + val legacy = RecordingSecretStore( + mutableMapOf( + sessionReference.targetName to "session-secret".encodeToByteArray(), + draftReference.targetName to "draft-secret".encodeToByteArray(), + ), + ) + val primary = RecordingSecretStore() + val adoption = RecordingSecretStoreAdoption() + val store = MigratingDesktopSecretStore(primary, legacy, adoption) + + assertContentEquals("session-secret".encodeToByteArray(), store.load(sessionReference)) + assertContentEquals("draft-secret".encodeToByteArray(), store.load(draftReference)) + + assertContentEquals("session-secret".encodeToByteArray(), primary.load(sessionReference)) + assertContentEquals("draft-secret".encodeToByteArray(), primary.load(draftReference)) + assertNull(legacy.load(sessionReference)) + assertNull(legacy.load(draftReference)) + assertEquals(DesktopSecretStoreAdoptionState.AdoptedAndClean, adoption.state(sessionReference)) + assertEquals(DesktopSecretStoreAdoptionState.AdoptedAndClean, adoption.state(draftReference)) + } + + @Test + fun adoptedKeychainReferenceNeverResurrectsAStaleLegacySecret() { + val reference = desktopSessionSecretReference("https://cloud.invalid", "alice") + val stale = "stale-session-secret".encodeToByteArray() + val legacy = RecordingSecretStore( + mutableMapOf(reference.targetName to stale), + ignoreClear = true, + ) + val primary = RecordingSecretStore() + val adoption = RecordingSecretStoreAdoption() + val store = MigratingDesktopSecretStore(primary, legacy, adoption) + + store.save(reference, "alice", "current-session-secret".encodeToByteArray()) + legacy.values[reference.targetName] = stale + store.clear(reference) + + assertNull(store.load(reference)) + assertContentEquals(stale, legacy.values.getValue(reference.targetName)) + } + + @Test + fun failedKeychainMigrationLeavesTheLegacySecretRetryable() { + val reference = desktopSessionSecretReference("https://cloud.invalid", "alice") + val expected = "legacy-session-secret".encodeToByteArray() + val legacy = RecordingSecretStore(mutableMapOf(reference.targetName to expected)) + val primary = RecordingSecretStore(failSave = true) + val adoption = RecordingSecretStoreAdoption() + val store = MigratingDesktopSecretStore(primary, legacy, adoption) + + assertFailsWith { store.load(reference) } + + assertContentEquals(expected, legacy.load(reference)) + assertEquals(DesktopSecretStoreAdoptionState.NotAdopted, adoption.state(reference)) + } + + @Test + fun throwingLegacyCleanupCannotBlockAnAdoptedKeychainValue() { + val reference = desktopSessionSecretReference("https://cloud.invalid", "alice") + val expected = "keychain-session-secret".encodeToByteArray() + val legacy = RecordingSecretStore( + mutableMapOf(reference.targetName to "legacy-session-secret".encodeToByteArray()), + failClear = true, + ) + val primary = RecordingSecretStore(mutableMapOf(reference.targetName to expected)) + val adoption = RecordingSecretStoreAdoption() + val store = MigratingDesktopSecretStore(primary, legacy, adoption) + + assertContentEquals(expected, store.load(reference)) + assertContentEquals(expected, store.load(reference)) + assertEquals(2, legacy.clearAttempts) + assertEquals( + DesktopSecretStoreAdoptionState.AdoptedPendingLegacyCleanup, + adoption.state(reference), + ) + } + + @Test + fun unavailableAdoptionMetadataCannotBlockAValidKeychainValue() { + val reference = desktopSessionSecretReference("https://cloud.invalid", "alice") + val expected = "keychain-session-secret".encodeToByteArray() + val store = MigratingDesktopSecretStore( + primary = RecordingSecretStore(mutableMapOf(reference.targetName to expected)), + legacy = RecordingSecretStore(), + adoption = RecordingSecretStoreAdoption(failWrites = true), + ) + + assertContentEquals(expected, store.load(reference)) + } + + @Test + fun unavailableAdoptionAndLegacyCleanupCannotExposeAKeychainValue() { + val reference = desktopSessionSecretReference("https://cloud.invalid", "alice") + val expected = "keychain-session-secret".encodeToByteArray() + val store = MigratingDesktopSecretStore( + primary = RecordingSecretStore(mutableMapOf(reference.targetName to expected)), + legacy = RecordingSecretStore( + mutableMapOf(reference.targetName to "stale-session-secret".encodeToByteArray()), + failClear = true, + ), + adoption = RecordingSecretStoreAdoption(failWrites = true), + ) + + assertFailsWith { store.load(reference) } + } + + @Test + fun queuedLegacyCleanupCannotBlockLocalSignOut() { + val reference = desktopSessionSecretReference("https://cloud.invalid", "alice") + val primary = RecordingSecretStore( + mutableMapOf(reference.targetName to "keychain-session-secret".encodeToByteArray()), + ) + val legacy = RecordingSecretStore( + mutableMapOf(reference.targetName to "legacy-session-secret".encodeToByteArray()), + failClearAttempts = 1, + ) + val adoption = RecordingSecretStoreAdoption() + val store = MigratingDesktopSecretStore(primary, legacy, adoption) + + store.clear(reference) + + assertNull(primary.load(reference)) + assertTrue(legacy.values.containsKey(reference.targetName)) + assertEquals( + DesktopSecretStoreAdoptionState.AdoptedPendingLegacyCleanup, + adoption.state(reference), + ) + + assertNull(store.load(reference)) + + assertNull(legacy.load(reference)) + assertEquals(2, legacy.clearAttempts) + assertEquals(DesktopSecretStoreAdoptionState.AdoptedAndClean, adoption.state(reference)) + } + + @Test + fun missingLegacyProviderCannotBlockLocalSignOut() { + val reference = desktopSessionSecretReference("https://cloud.invalid", "alice") + val primary = RecordingSecretStore( + mutableMapOf(reference.targetName to "keychain-session-secret".encodeToByteArray()), + ) + val adoption = RecordingSecretStoreAdoption() + val store = MigratingDesktopSecretStore( + primary, + SecretToolDesktopSecretStore( + startProcess = { throw java.io.IOException("synthetic missing executable") }, + ), + adoption, + ) + + store.clear(reference) + + assertNull(primary.load(reference)) + assertEquals( + DesktopSecretStoreAdoptionState.AdoptedPendingLegacyCleanup, + adoption.state(reference), + ) + } + + @Test + fun unqueuedLegacyCleanupFailureRemainsActionable() { + val reference = desktopSessionSecretReference("https://cloud.invalid", "alice") + val primary = RecordingSecretStore( + mutableMapOf(reference.targetName to "keychain-session-secret".encodeToByteArray()), + ) + val legacy = RecordingSecretStore( + mutableMapOf(reference.targetName to "legacy-session-secret".encodeToByteArray()), + failClear = true, + ) + val store = MigratingDesktopSecretStore( + primary, + legacy, + RecordingSecretStoreAdoption(failWrites = true), + ) + + assertFailsWith { store.clear(reference) } + + assertNull(primary.load(reference)) + assertTrue(legacy.values.containsKey(reference.targetName)) + } + + @Test + fun failedKeychainClearStillRemovesTheLegacyCredential() { + val reference = desktopSessionSecretReference("https://cloud.invalid", "alice") + val primary = RecordingSecretStore( + mutableMapOf(reference.targetName to "keychain-session-secret".encodeToByteArray()), + failClear = true, + ) + val legacy = RecordingSecretStore( + mutableMapOf(reference.targetName to "legacy-session-secret".encodeToByteArray()), + ) + val store = MigratingDesktopSecretStore(primary, legacy, RecordingSecretStoreAdoption()) + + assertFailsWith { store.clear(reference) } + + assertNull(legacy.load(reference)) + assertEquals(1, legacy.clearAttempts) + } + + @Test + fun failedLegacyCleanupRetriesWithoutReadingTheStaleValueAgain() { + val reference = desktopSessionSecretReference("https://cloud.invalid", "alice") + val expected = "keychain-session-secret".encodeToByteArray() + val legacy = RecordingSecretStore( + mutableMapOf(reference.targetName to "legacy-session-secret".encodeToByteArray()), + failClearAttempts = 1, + ) + val primary = RecordingSecretStore(mutableMapOf(reference.targetName to expected)) + val adoption = RecordingSecretStoreAdoption() + val store = MigratingDesktopSecretStore(primary, legacy, adoption) + + assertContentEquals(expected, store.load(reference)) + assertEquals(DesktopSecretStoreAdoptionState.AdoptedPendingLegacyCleanup, adoption.state(reference)) + assertContentEquals(expected, store.load(reference)) + + assertEquals(2, legacy.clearAttempts) + assertEquals(DesktopSecretStoreAdoptionState.AdoptedAndClean, adoption.state(reference)) + assertNull(legacy.load(reference)) + } + + @Test + fun freshDraftKeyCanBeCreatedWhenNoDraftDependsOnAnUnavailableLegacyStore() { + val secrets = RecordingSecretStore(failLoadAttempts = 1) + val provider = PlatformDeckDraftKeyProvider( + secretStore = secrets, + legacySecretRequired = { false }, + ) + + val key = provider.encryptionKey() + + assertEquals(DesktopDeckCardDraftStore.AES_KEY_BYTES, key.size) + assertTrue(secrets.values.containsKey(desktopDeckDraftSecretReference().targetName)) + } + + @Test + fun freshMacOsDraftKeyCanBypassAMissingLegacyProvider() { + val primary = RecordingSecretStore() + val migrating = MigratingDesktopSecretStore( + primary = primary, + legacy = SecretToolDesktopSecretStore( + startProcess = { throw java.io.IOException("synthetic missing executable") }, + ), + adoption = RecordingSecretStoreAdoption(), + ) + val provider = PlatformDeckDraftKeyProvider( + secretStore = migrating, + legacySecretRequired = { false }, + ) + + val key = provider.encryptionKey() + + assertEquals(DesktopDeckCardDraftStore.AES_KEY_BYTES, key.size) + assertTrue(primary.values.containsKey(desktopDeckDraftSecretReference().targetName)) + } + + @Test + fun existingDraftNeverCreatesAReplacementKeyAfterAmbiguousLegacyLookup() { + val provider = PlatformDeckDraftKeyProvider( + secretStore = RecordingSecretStore(failLoad = true), + legacySecretRequired = { true }, + ) + + assertFailsWith { provider.encryptionKey() } + } + + @Test + fun existingDraftNeverCreatesAReplacementForAConfirmedMissingKey() { + val secrets = RecordingSecretStore() + val provider = PlatformDeckDraftKeyProvider( + secretStore = secrets, + legacySecretRequired = { true }, + ) + + assertFailsWith { provider.encryptionKey() } + assertFalse(secrets.values.containsKey(desktopDeckDraftSecretReference().targetName)) + } + + @Test + fun existingDraftNeverCreatesAReplacementForAMalformedKey() { + val reference = desktopDeckDraftSecretReference() + val secrets = RecordingSecretStore( + mutableMapOf(reference.targetName to "not-base64".encodeToByteArray()), + ) + val provider = PlatformDeckDraftKeyProvider( + secretStore = secrets, + legacySecretRequired = { true }, + ) + + assertFailsWith { provider.encryptionKey() } + assertContentEquals("not-base64".encodeToByteArray(), secrets.values.getValue(reference.targetName)) + } + + @Test + fun macOsKeychainDenialIsActionableAndDoesNotExposeCredentialIdentity() { + val store = MacOsKeychainSecretStore( + api = FakeMacOsKeychainApi(findFailure = -25_293), + releaseItem = {}, + deletionRecovery = RecordingMacOsKeychainDeletionRecovery(), + ) + val reference = desktopSessionSecretReference("https://private.invalid", "synthetic-user") + + val failure = assertFailsWith { + store.load(reference) + } + + assertTrue(failure.message.orEmpty().contains("denied")) + assertFalse(failure.message.orEmpty().contains("private.invalid")) + assertFalse(failure.message.orEmpty().contains("synthetic-user")) + } + + @Test + fun malformedMacOsKeychainValueIsRemovedAndReturnsToSignIn() { + val api = FakeMacOsKeychainApi(initialSecret = ByteArray(2_561)) + val store = MacOsKeychainSecretStore( + api, + releaseItem = {}, + deletionRecovery = RecordingMacOsKeychainDeletionRecovery(), + ) + val reference = desktopSessionSecretReference("https://cloud.invalid", "alice") + + assertNull(store.load(reference)) + assertEquals(1, api.deleteAttempts) + assertNull(store.load(reference)) + } + + @Test + fun macOsKeychainConcurrentAddRaceUpdatesTheExistingItem() { + val api = FakeMacOsKeychainApi(duplicateOnFirstAdd = true) + val store = MacOsKeychainSecretStore( + api, + releaseItem = {}, + deletionRecovery = RecordingMacOsKeychainDeletionRecovery(), + ) + val reference = desktopSessionSecretReference("https://cloud.invalid", "alice") + val expected = "replacement-synthetic-secret".encodeToByteArray() + + store.save(reference, "alice", expected) + + assertContentEquals(expected, store.load(reference)) + } + + @Test + fun failedMacOsKeychainDeletionRetriesAfterProcessRestart() { + val api = FakeMacOsKeychainApi( + initialSecret = "synthetic-session-secret".encodeToByteArray(), + deleteFailureAttempts = 1, + ) + val recovery = RecordingMacOsKeychainDeletionRecovery() + val reference = desktopSessionSecretReference("https://cloud.invalid", "alice") + val firstProcess = MacOsKeychainSecretStore(api, releaseItem = {}, deletionRecovery = recovery) + + assertFailsWith { firstProcess.clear(reference) } + assertEquals(setOf(reference.targetName), recovery.pendingTargetNames()) + assertEquals(1, api.deleteAttempts) + + val restarted = MacOsKeychainSecretStore(api, releaseItem = {}, deletionRecovery = recovery) + + assertNull(restarted.load(reference)) + assertEquals(emptySet(), recovery.pendingTargetNames()) + assertEquals(2, api.deleteAttempts) + } + + @Test + fun ambiguousDeletionCompletionRemainsRetryableBeforeReplacementSave() { + val api = FakeMacOsKeychainApi(initialSecret = "old-synthetic-secret".encodeToByteArray()) + val recovery = RecordingMacOsKeychainDeletionRecovery(failCompleteAttempts = 1) + val reference = desktopSessionSecretReference("https://cloud.invalid", "alice") + val store = MacOsKeychainSecretStore(api, releaseItem = {}, deletionRecovery = recovery) + + assertFailsWith { store.clear(reference) } + assertEquals(setOf(reference.targetName), recovery.pendingTargetNames()) + + val replacement = "replacement-synthetic-secret".encodeToByteArray() + store.save(reference, "alice", replacement) + + assertContentEquals(replacement, store.load(reference)) + assertEquals(emptySet(), recovery.pendingTargetNames()) + assertEquals(1, api.deleteAttempts) + } + + @Test + fun unavailableRecoveryJournalPreventsUntrackedKeychainDeletion() { + val api = FakeMacOsKeychainApi(initialSecret = "synthetic-session-secret".encodeToByteArray()) + val recovery = RecordingMacOsKeychainDeletionRecovery(failPending = true) + val reference = desktopSessionSecretReference("https://cloud.invalid", "alice") + val store = MacOsKeychainSecretStore(api, releaseItem = {}, deletionRecovery = recovery) + + assertFailsWith { store.clear(reference) } + + assertEquals(0, api.deleteAttempts) + assertContentEquals("synthetic-session-secret".encodeToByteArray(), store.load(reference)) + } + + @Test + fun ambiguousJournalCompletionStaysPendingUntilDurablyRecorded() { + val preferences = Preferences.userRoot().node( + "dev/obiente/nextcloudnative/test-keychain-deletion/${UUID.randomUUID()}", + ) + var flushAttempts = 0 + val recovery = PreferencesMacOsKeychainDeletionRecovery(preferences) { node -> + flushAttempts += 1 + if (flushAttempts == 2) throw BackingStoreException("Synthetic flush failure.") + node.flush() + } + val targetName = desktopSessionSecretReference("https://cloud.invalid", "alice").targetName + try { + recovery.markPending(targetName) + + assertFailsWith { recovery.markComplete(targetName) } + assertEquals(setOf(targetName), recovery.pendingTargetNames()) + + recovery.markComplete(targetName) + assertEquals(emptySet(), recovery.pendingTargetNames()) + } finally { + preferences.removeNode() + } + } + + private class FakeMacOsKeychainApi( + private val findFailure: Int? = null, + private val duplicateOnFirstAdd: Boolean = false, + initialSecret: ByteArray? = null, + private var deleteFailureAttempts: Int = 0, + ) : MacOsKeychainApi { + private var secret: ByteArray? = initialSecret + private var addAttempted = false + private var returnedSecret: Memory? = null + private val item = Memory(1) + var lastService: String? = null + private set + var lastAccount: String? = null + private set + var deleteAttempts: Int = 0 + private set + + override fun SecKeychainFindGenericPassword( + keychainOrArray: Pointer?, + serviceNameLength: Int, + serviceName: ByteArray, + accountNameLength: Int, + accountName: ByteArray, + secretLength: IntByReference?, + secretData: PointerByReference?, + itemRef: PointerByReference, + ): Int { + lastService = serviceName.copyOf(serviceNameLength).decodeToString() + lastAccount = accountName.copyOf(accountNameLength).decodeToString() + findFailure?.let { return it } + val stored = secret ?: return -25_300 + if (secretLength != null && secretData != null) { + returnedSecret = Memory(stored.size.toLong()).also { memory -> + memory.write(0, stored, 0, stored.size) + secretData.value = memory + } + secretLength.value = stored.size + } + itemRef.value = item + return 0 + } + + override fun SecKeychainAddGenericPassword( + keychain: Pointer?, + serviceNameLength: Int, + serviceName: ByteArray, + accountNameLength: Int, + accountName: ByteArray, + secretLength: Int, + secretData: ByteArray, + itemRef: PointerByReference?, + ): Int { + if (duplicateOnFirstAdd && !addAttempted) { + addAttempted = true + secret = "concurrent-synthetic-secret".encodeToByteArray() + return -25_299 + } + secret = secretData.copyOf(secretLength) + return 0 + } + + override fun SecKeychainItemModifyAttributesAndData( + itemRef: Pointer, + attributes: Pointer?, + secretLength: Int, + secretData: ByteArray, + ): Int { + secret = secretData.copyOf(secretLength) + return 0 + } + + override fun SecKeychainItemDelete(itemRef: Pointer): Int { + deleteAttempts += 1 + if (deleteFailureAttempts > 0) { + deleteFailureAttempts -= 1 + return -25_308 + } + secret = null + return 0 + } + + override fun SecKeychainItemFreeContent(attributes: Pointer?, secretData: Pointer?): Int { + returnedSecret?.clear() + returnedSecret = null + return 0 + } + } + + private class RecordingMacOsKeychainDeletionRecovery( + private val failPending: Boolean = false, + private var failCompleteAttempts: Int = 0, + ) : MacOsKeychainDeletionRecovery { + private val pending = linkedSetOf() + + override fun pendingTargetNames(): Set = pending.toSet() + + override fun markPending(targetName: String) { + if (failPending) error("Synthetic unavailable deletion recovery.") + pending += targetName + } + + override fun markComplete(targetName: String) { + if (failCompleteAttempts > 0) { + failCompleteAttempts -= 1 + error("Synthetic ambiguous deletion completion.") + } + pending -= targetName + } + } + + private class RecordingSecretStore( + val values: MutableMap = mutableMapOf(), + private val failSave: Boolean = false, + private val failLoad: Boolean = false, + private var failLoadAttempts: Int = 0, + private val ignoreClear: Boolean = false, + private val failClear: Boolean = false, + private var failClearAttempts: Int = 0, + ) : DesktopSecretStore { + var clearAttempts = 0 + private set + + override fun load(reference: DesktopSecretReference): ByteArray? { + if (failLoad || failLoadAttempts > 0) { + if (failLoadAttempts > 0) failLoadAttempts -= 1 + throw DesktopSecretStoreUnavailableException("Synthetic unavailable store.") + } + return values[reference.targetName]?.copyOf() + } + + override fun save(reference: DesktopSecretReference, username: String?, secret: ByteArray) { + if (failSave) throw DesktopSecretStoreUnavailableException("Synthetic unavailable store.") + values[reference.targetName] = secret.copyOf() + } + + override fun clear(reference: DesktopSecretReference) { + clearAttempts += 1 + if (failClear || failClearAttempts > 0) { + if (failClearAttempts > 0) failClearAttempts -= 1 + error("Synthetic legacy cleanup failure.") + } + if (!ignoreClear) values.remove(reference.targetName) + } + } + + private class RecordingSecretStoreAdoption( + private val failWrites: Boolean = false, + ) : DesktopSecretStoreAdoption { + private val states = mutableMapOf() + + override fun state(reference: DesktopSecretReference): DesktopSecretStoreAdoptionState = + states[reference.targetName] ?: DesktopSecretStoreAdoptionState.NotAdopted + + override fun markAdopted(reference: DesktopSecretReference) { + if (failWrites) error("Synthetic unavailable adoption metadata.") + states[reference.targetName] = DesktopSecretStoreAdoptionState.AdoptedPendingLegacyCleanup + } + + override fun markLegacyCleanupComplete(reference: DesktopSecretReference) { + if (failWrites) error("Synthetic unavailable adoption metadata.") + check(state(reference) != DesktopSecretStoreAdoptionState.NotAdopted) + states[reference.targetName] = DesktopSecretStoreAdoptionState.AdoptedAndClean + } } @Test diff --git a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopSyncLifecycleRecoveryTest.kt b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopSyncLifecycleRecoveryTest.kt new file mode 100644 index 000000000..e15548ac2 --- /dev/null +++ b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopSyncLifecycleRecoveryTest.kt @@ -0,0 +1,45 @@ +package dev.obiente.nextcloudnative.app + +import kotlinx.coroutines.runBlocking +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class DesktopSyncLifecycleRecoveryTest { + @Test + fun secureStorageFailureLeavesTheNextBackgroundReconciliationRetryable() = runBlocking { + val expected = NextcloudSession("https://cloud.invalid", "alice", "synthetic-secret") + var attempts = 0 + val reconciled = mutableListOf() + val load = { + attempts += 1 + if (attempts == 1) { + throw NextcloudSessionStorageUnavailableException("synthetic locked keychain") + } + expected + } + + assertFalse(reconcileDesktopBackgroundSession(load, reconcile = { reconciled += it })) + assertTrue(reconcileDesktopBackgroundSession(load, reconcile = { reconciled += it })) + + assertEquals(listOf(expected), reconciled) + } + + @Test + fun missingLegacyMigrationProviderDefersBackgroundReconciliation() = runBlocking { + var reconciled = false + + assertFalse( + reconcileDesktopBackgroundSession( + loadSession = { + throw NextcloudSessionLegacyMigrationUnavailableException( + NextcloudSessionStorageUnavailableException("synthetic missing provider"), + ) + }, + reconcile = { reconciled = true }, + ), + ) + assertFalse(reconciled) + } +} diff --git a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/JvmSupportIntakeTest.kt b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/JvmSupportIntakeTest.kt index 5f934a9d6..e5d7b837c 100644 --- a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/JvmSupportIntakeTest.kt +++ b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/JvmSupportIntakeTest.kt @@ -2405,7 +2405,9 @@ class JvmSupportIntakeTest { val submission = launch(Dispatchers.Default) { fixture.intake.submit("A refresh failed.", "nightly", emptyList()) } - val upload = requireNotNull(fixture.server.takeRequest(2, TimeUnit.SECONDS)) + val upload = requireNotNull( + fixture.server.takeRequest(WINDOWS_REQUEST_START_TIMEOUT_SECONDS, TimeUnit.SECONDS), + ) assertTrue(fixture.intake.cancel()) submission.join() requireNotNull(fixture.server.takeRequest(2, TimeUnit.SECONDS)) diff --git a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/NextcloudSessionLoadingInteractionTest.kt b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/NextcloudSessionLoadingInteractionTest.kt new file mode 100644 index 000000000..139b0f9a0 --- /dev/null +++ b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/NextcloudSessionLoadingInteractionTest.kt @@ -0,0 +1,56 @@ +package dev.obiente.nextcloudnative.app + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class NextcloudSessionLoadingInteractionTest { + @Test + fun unavailableSecureStorageOffersRetryAndStoredSessionReset() { + var retries = 0 + var resets = 0 + + nativeSceneTest(390, 844, content = { + SecureSessionStorageUnavailable( + onRetry = { retries += 1 }, + onSignInAgain = { resets += 1 }, + ) + }) { + assertTrue( + has( + "Secure session storage is locked or unavailable. Unlock it or allow " + + "Nextcloud Native access, then try again, or discard the stored session and sign in again.", + ), + ) + click("Try again") + click("Sign in again") + assertEquals(1, retries) + assertEquals(1, resets) + } + } + + + @Test + fun unavailableLegacyMigrationOffersAStoredSessionReset() { + var retries = 0 + var resets = 0 + + nativeSceneTest(390, 844, content = { + LegacySessionMigrationUnavailable( + onRetry = { retries += 1 }, + onSignInAgain = { resets += 1 }, + ) + }) { + assertTrue( + has( + "The previous session needs the legacy secure-storage provider. Install the provider " + + "and try again, or discard the stored session and sign in again.", + ), + ) + click("Try again") + click("Sign in again") + assertEquals(1, retries) + assertEquals(1, resets) + } + } +} diff --git a/website/public/screenshots/capture-manifest.json b/website/public/screenshots/capture-manifest.json index c1fdcaa32..cf13c6660 100644 --- a/website/public/screenshots/capture-manifest.json +++ b/website/public/screenshots/capture-manifest.json @@ -208,6 +208,8 @@ "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudPhotoEditor.kt", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudPlatform.kt", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudResponseLimits.kt", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudSessionLoading.kt", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudStatusMessages.kt", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NotesApi.kt", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NotesFolderOperations.kt", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/OfficeDocumentWorkflow.kt", @@ -600,13 +602,15 @@ "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudLinkRouting.kt": "5b29a90b69bb32aba118ef6c8b3f9d6eb26c03835823119b4a0f5bb1c1f4cb17", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudMediaViewer.kt": "fb424bf8979ac292ef30daba64e905e6f5123066cda793aa0244907e11b465c9", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudMediaViewerActions.kt": "48aaed6948d1423113d300cc3ab86d244ab76e8225e24988e9855edf74553944", - "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNativeApp.kt": "c7966b9c1a6fc385cbbf239cb73059411ca5dda8a01ea23d7e54f17fab464d7c", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNativeApp.kt": "80d7e5db29193b835b94be990747195234b5dfad6116d853a627f5172aea19ae", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNotes.kt": "14d43a632afa7c5bf970d90d1387285624182be00569b57c0955670de017cc6c", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNotesCache.kt": "9223dd455c6a1c35a10769616fceb9dfb2d92ef4d43b0a52c2c29e40f1a196cf", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudPeople.kt": "cff910ea2cc77211ef81779c49ee0c957851f2b4a3ed32b857b12ded1cee643b", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudPhotoEditor.kt": "34d9b43cf3bbfc8342958dc40f2df7b4573f30a2ae1bd9bcb8bb470151313a3d", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudPlatform.kt": "058a09681e46b10dd0145dac4ca16df5491ba4546ac8b319acf7c29076804b51", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudResponseLimits.kt": "fead6cbf4f723ea46f99c9155b190967205d24f198687b7bfd5a213c474374b8", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudSessionLoading.kt": "0abcfe2da22b8e49f6ee292d8b340cc36dafd2cf0658cbb8ca847481a351fe19", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudStatusMessages.kt": "5154dd95c432c91e306c09372ef7367f6b43be8c9d52826b36ecd8ecbfcbf3dc", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NotesApi.kt": "e3c685b02592ea791b0c9a478a098fc5bf428893aa4764110c793a78040f341e", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NotesFolderOperations.kt": "db6a04cfcd3b25b17c996cdc6dba21847d14f5970e06e6c24a3e2c85139dbe1d", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/OfficeDocumentWorkflow.kt": "48057ebf53e45a042c4283aaff9a2ca5c3bb46fff3356e962540409f8c7b3b04",