diff --git a/app/src/full/java/de/jeanlucmakiola/agendula/data/sync/AccountRepository.kt b/app/src/full/java/de/jeanlucmakiola/agendula/data/sync/AccountRepository.kt index 505a80f..9ab6c01 100644 --- a/app/src/full/java/de/jeanlucmakiola/agendula/data/sync/AccountRepository.kt +++ b/app/src/full/java/de/jeanlucmakiola/agendula/data/sync/AccountRepository.kt @@ -7,6 +7,7 @@ import de.jeanlucmakiola.agendula.data.tasks.room.AccountEntity import de.jeanlucmakiola.agendula.data.tasks.room.TaskListEntity import de.jeanlucmakiola.agendula.data.tasks.room.TasksDatabase import de.jeanlucmakiola.caldav.CalDavDiscovery +import de.jeanlucmakiola.caldav.ServerTrust import de.jeanlucmakiola.caldav.TaskCollection import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineDispatcher @@ -35,7 +36,12 @@ interface AccountCreator { selected: Set, /** An existing account to sign in again, instead of creating one. */ reauthenticating: Long? = null, + /** What the user agreed to trust on the way here; saved with the account. */ + trust: ServerTrust = ServerTrust.DEFAULT, ): AccountRepository.Outcome + + /** What [accountId] was set up trusting, so signing in again starts from it. */ + suspend fun trustFor(accountId: Long): ServerTrust } /** @@ -59,6 +65,7 @@ class AccountRepository @Inject constructor( private val quarantine: QuarantineStore, private val notices: SyncNoticeStore, private val collectionSupport: CollectionSupportStore, + private val serverTrust: ServerTrustStore, private val gateway: CalDavGateway, private val availability: SyncAvailability, private val settings: SettingsPrefs, @@ -120,6 +127,7 @@ class AccountRepository @Inject constructor( found: CalDavDiscovery.Outcome.Found, selected: Set, reauthenticating: Long?, + trust: ServerTrust, ): Outcome = withContext(io) { if (!availability.accountsUsable()) return@withContext Outcome.ExternalStorage // "Sign in again" names its account, so a login name the server spells @@ -127,7 +135,7 @@ class AccountRepository @Inject constructor( // Only on the same server: anything else is a different account. val target = reauthenticating?.let { database.accounts().account(it) } if (target != null && sameServer(target, found)) { - return@withContext reauthenticate(target, target.displayName, username, appPassword, found, selected) + return@withContext reauthenticate(target, target.displayName, username, appPassword, found, selected, trust) } // Both stores, not just one. There is no unique index on // accounts.display_name and nothing prunes Room when the system account @@ -144,7 +152,7 @@ class AccountRepository @Inject constructor( // same name is still a duplicate, so this can never silently overwrite a // working credential. if (existing != null && accountState.needsSignIn(existing.id)) { - return@withContext reauthenticate(existing, displayName, username, appPassword, found, selected) + return@withContext reauthenticate(existing, displayName, username, appPassword, found, selected, trust) } if (existing != null) return@withContext Outcome.AlreadyExists @@ -185,6 +193,7 @@ class AccountRepository @Inject constructor( inserted += attach(id, selected) id } + serverTrust.put(accountId, trust) if (!credentials.put(accountId, appPassword)) { // Never leave a half-made account behind: without a credential it @@ -212,6 +221,8 @@ class AccountRepository @Inject constructor( } } + override suspend fun trustFor(accountId: Long): ServerTrust = serverTrust.get(accountId) + /** * Points [selected] at [accountId], re-attaching what a previous removal * left behind rather than inserting a second copy. @@ -303,6 +314,7 @@ class AccountRepository @Inject constructor( val outcome = gateway.discover( principal.toString(), CalDavGateway.Credentials(username, password, principal), + serverTrust.get(accountId), ) ) { is CalDavDiscovery.Outcome.Found -> Collections.Found(outcome.collections, attached) @@ -311,6 +323,9 @@ class AccountRepository @Inject constructor( -> Collections.NeedsSignIn is CalDavDiscovery.Outcome.NotCalDav -> Collections.Failed(outcome.cause) is CalDavDiscovery.Outcome.Failed -> Collections.Failed(outcome.cause) + // A certificate that changed since it was trusted; signing in again re-asks. + is CalDavDiscovery.Outcome.UntrustedCertificate -> + Collections.Failed(CalDavDiscovery.Outcome.Cause.TLS) } } @@ -391,6 +406,7 @@ class AccountRepository @Inject constructor( appPassword: String, found: CalDavDiscovery.Outcome.Found, selected: Set, + trust: ServerTrust, ): Outcome { val accountId = existing.id if (!credentials.put(accountId, appPassword)) { @@ -413,6 +429,7 @@ class AccountRepository @Inject constructor( ) attach(accountId, selected) } + serverTrust.put(accountId, trust) // ⚠️ Ticked lists are attached; unticked ones are left alone. The // picker pre-ticks everything *writable*, not everything already // attached, so detaching what is unticked would silently stop @@ -442,9 +459,12 @@ class AccountRepository @Inject constructor( * and holds the user's tasks; `SET NULL` returns it to being device-only, * which is exactly where it came from. */ - private fun rollback(accountId: Long, inserted: List) = database.runInTransaction { - inserted.forEach { database.taskLists().delete(it) } - database.accounts().delete(accountId) + private suspend fun rollback(accountId: Long, inserted: List) { + database.runInTransaction { + inserted.forEach { database.taskLists().delete(it) } + database.accounts().delete(accountId) + } + serverTrust.forget(accountId) } /** @@ -535,6 +555,7 @@ class AccountRepository @Inject constructor( // The same reasoning, for what the server said it would let us // create: keyed by an id nothing will ever mention again. collectionSupport.forget(accountId) + serverTrust.forget(accountId) credentials.clear(accountId) database.accounts().delete(accountId) accounts.find(displayName)?.let { accounts.remove(it) } @@ -562,6 +583,7 @@ class AccountRepository @Inject constructor( try { gateway.revokeAppPassword( CalDavGateway.Credentials(username, password, origin), + serverTrust.get(accountId), ) } catch (_: CancellationException) { // Deliberately swallowed. If the caller went away mid-revoke we still diff --git a/app/src/full/java/de/jeanlucmakiola/agendula/data/sync/CalDavGateway.kt b/app/src/full/java/de/jeanlucmakiola/agendula/data/sync/CalDavGateway.kt index 46ac615..d556a58 100644 --- a/app/src/full/java/de/jeanlucmakiola/agendula/data/sync/CalDavGateway.kt +++ b/app/src/full/java/de/jeanlucmakiola/agendula/data/sync/CalDavGateway.kt @@ -5,6 +5,7 @@ import de.jeanlucmakiola.caldav.AppPassword import de.jeanlucmakiola.caldav.CalDavDiscovery import de.jeanlucmakiola.caldav.CalDavHttp import de.jeanlucmakiola.caldav.NextcloudLoginFlow +import de.jeanlucmakiola.caldav.ServerTrust import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.job import kotlinx.coroutines.withContext @@ -25,12 +26,22 @@ import javax.inject.Singleton interface CalDavGateway { /** Discovery against [target], optionally carrying credentials. */ - suspend fun discover(target: String, credentials: Credentials? = null): CalDavDiscovery.Outcome + suspend fun discover( + target: String, + credentials: Credentials? = null, + trust: ServerTrust = ServerTrust.DEFAULT, + ): CalDavDiscovery.Outcome /** Starts Nextcloud Login Flow v2, or returns null if this is not a Nextcloud. */ - suspend fun startLoginFlow(server: HttpUrl): NextcloudLoginFlow.Flow? + suspend fun startLoginFlow( + server: HttpUrl, + trust: ServerTrust = ServerTrust.DEFAULT, + ): NextcloudLoginFlow.Flow? - suspend fun pollLoginFlow(flow: NextcloudLoginFlow.Flow): NextcloudLoginFlow.PollResult + suspend fun pollLoginFlow( + flow: NextcloudLoginFlow.Flow, + trust: ServerTrust = ServerTrust.DEFAULT, + ): NextcloudLoginFlow.PollResult /** * Hands an app password back to the server, best effort. @@ -38,7 +49,10 @@ interface CalDavGateway { * Without it, uninstalling never revokes anything — the credential we minted * outlives the app in the user's device list. */ - suspend fun revokeAppPassword(credentials: Credentials): Boolean + suspend fun revokeAppPassword( + credentials: Credentials, + trust: ServerTrust = ServerTrust.DEFAULT, + ): Boolean /** * The same, for a password the login flow just minted. @@ -49,7 +63,10 @@ interface CalDavGateway { * principal, and a subpath install's `https://host/nextcloud/` would collapse * to `https://host/` — a DELETE that 404s on every one of them. */ - suspend fun revokeIssuedAppPassword(credentials: Credentials): Boolean + suspend fun revokeIssuedAppPassword( + credentials: Credentials, + trust: ServerTrust = ServerTrust.DEFAULT, + ): Boolean /** Credentials, and the origin whose registrable domain they are scoped to. */ data class Credentials(val username: String, val password: String, val origin: HttpUrl) @@ -71,17 +88,19 @@ class OkHttpCalDavGateway @Inject constructor( override suspend fun discover( target: String, credentials: CalDavGateway.Credentials?, + trust: ServerTrust, ): CalDavDiscovery.Outcome = withContext(io) { val client = credentials?.let { - CalDavHttp.authenticated(userAgent, it.username, it.password, it.origin) - } ?: CalDavHttp.anonymous(userAgent) + CalDavHttp.authenticated(userAgent, it.username, it.password, it.origin, trust) + } ?: CalDavHttp.anonymous(userAgent, trust) val job = coroutineContext.job - CalDavDiscovery(client, SystemDnsResolver { !job.isActive }).discover(target) + CalDavDiscovery(client, SystemDnsResolver { !job.isActive }, allowCleartext = trust.allowCleartext) + .discover(target) } - override suspend fun startLoginFlow(server: HttpUrl): NextcloudLoginFlow.Flow? = + override suspend fun startLoginFlow(server: HttpUrl, trust: ServerTrust): NextcloudLoginFlow.Flow? = withContext(io) { - NextcloudLoginFlow(CalDavHttp.anonymous(userAgent), userAgent) + NextcloudLoginFlow(CalDavHttp.anonymous(userAgent, trust), userAgent) .start(server, now()) .getOrNull() } @@ -93,9 +112,12 @@ class OkHttpCalDavGateway @Inject constructor( * the shared client's ceiling is sized for a multiget, not for a two-second * poll loop against a server that answered a moment ago. */ - override suspend fun pollLoginFlow(flow: NextcloudLoginFlow.Flow): NextcloudLoginFlow.PollResult = + override suspend fun pollLoginFlow( + flow: NextcloudLoginFlow.Flow, + trust: ServerTrust, + ): NextcloudLoginFlow.PollResult = withContext(io) { - val client = CalDavHttp.anonymous(userAgent).newBuilder() + val client = CalDavHttp.anonymous(userAgent, trust).newBuilder() .callTimeout(POLL_TIMEOUT_SECONDS, TimeUnit.SECONDS) .build() NextcloudLoginFlow(client, userAgent).poll(flow, now()) @@ -103,18 +125,20 @@ class OkHttpCalDavGateway @Inject constructor( override suspend fun revokeAppPassword( credentials: CalDavGateway.Credentials, + trust: ServerTrust, ): Boolean = withContext(io) { val client = CalDavHttp.authenticated( - userAgent, credentials.username, credentials.password, credentials.origin, + userAgent, credentials.username, credentials.password, credentials.origin, trust, ) AppPassword.revoke(client, credentials.origin) } override suspend fun revokeIssuedAppPassword( credentials: CalDavGateway.Credentials, + trust: ServerTrust, ): Boolean = withContext(io) { val client = CalDavHttp.authenticated( - userAgent, credentials.username, credentials.password, credentials.origin, + userAgent, credentials.username, credentials.password, credentials.origin, trust, ) AppPassword.revokeAt(client, credentials.origin) } diff --git a/app/src/full/java/de/jeanlucmakiola/agendula/data/sync/PendingLoginFlowStore.kt b/app/src/full/java/de/jeanlucmakiola/agendula/data/sync/PendingLoginFlowStore.kt index 2f0280d..efcf17c 100644 --- a/app/src/full/java/de/jeanlucmakiola/agendula/data/sync/PendingLoginFlowStore.kt +++ b/app/src/full/java/de/jeanlucmakiola/agendula/data/sync/PendingLoginFlowStore.kt @@ -2,11 +2,14 @@ package de.jeanlucmakiola.agendula.data.sync import androidx.datastore.core.DataStore import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.booleanPreferencesKey import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.longPreferencesKey import androidx.datastore.preferences.core.stringPreferencesKey +import androidx.datastore.preferences.core.stringSetPreferencesKey import de.jeanlucmakiola.agendula.data.di.SyncStateDataStore import de.jeanlucmakiola.caldav.NextcloudLoginFlow +import de.jeanlucmakiola.caldav.ServerTrust import kotlinx.coroutines.flow.first import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock @@ -23,8 +26,8 @@ import javax.inject.Singleton */ interface LoginFlowRecord { - /** Called **before** the browser is handed the URL. */ - suspend fun remember(flow: NextcloudLoginFlow.Flow) + /** Called **before** the browser is handed the URL; [trust] is what reaching it took. */ + suspend fun remember(flow: NextcloudLoginFlow.Flow, trust: ServerTrust = ServerTrust.DEFAULT) /** The flow is over, however it ended. */ suspend fun forget() @@ -58,12 +61,14 @@ class PendingLoginFlowStore @Inject constructor( private var reclaimed = false /** Records [flow] so a process that dies mid-approval can still finish with it. */ - override suspend fun remember(flow: NextcloudLoginFlow.Flow) { + override suspend fun remember(flow: NextcloudLoginFlow.Flow, trust: ServerTrust) { dataStore.edit { prefs -> prefs[LOGIN_URL] = flow.loginUrl.toString() prefs[POLL_ENDPOINT] = flow.pollEndpoint.toString() prefs[POLL_TOKEN] = flow.pollToken prefs[DEADLINE] = flow.deadlineEpochSeconds + prefs[TRUSTED_CERTIFICATES] = trust.pinnedCertificates + prefs[CLEARTEXT] = trust.allowCleartext } } @@ -74,6 +79,8 @@ class PendingLoginFlowStore @Inject constructor( prefs.remove(POLL_ENDPOINT) prefs.remove(POLL_TOKEN) prefs.remove(DEADLINE) + prefs.remove(TRUSTED_CERTIFICATES) + prefs.remove(CLEARTEXT) } } @@ -98,7 +105,8 @@ class PendingLoginFlowStore @Inject constructor( reclaimed = true } val flow = pending() ?: return - when (val result = gateway.pollLoginFlow(flow)) { + val trust = trust() + when (val result = gateway.pollLoginFlow(flow, trust)) { is NextcloudLoginFlow.PollResult.Approved -> { // Cleared first: a revocation that fails must not leave a token // that would be polled again, and the 200 is already spent. @@ -109,6 +117,7 @@ class PendingLoginFlowStore @Inject constructor( password = result.credentials.appPassword, origin = result.credentials.server, ), + trust, ) } @@ -136,10 +145,20 @@ class PendingLoginFlowStore @Inject constructor( ) } + private suspend fun trust(): ServerTrust { + val prefs = dataStore.data.first() + return ServerTrust( + pinnedCertificates = prefs[TRUSTED_CERTIFICATES].orEmpty(), + allowCleartext = prefs[CLEARTEXT] ?: false, + ) + } + private companion object { val LOGIN_URL = stringPreferencesKey("login_flow_url") val POLL_ENDPOINT = stringPreferencesKey("login_flow_poll_endpoint") val POLL_TOKEN = stringPreferencesKey("login_flow_poll_token") val DEADLINE = longPreferencesKey("login_flow_deadline") + val TRUSTED_CERTIFICATES = stringSetPreferencesKey("login_flow_trusted_certificates") + val CLEARTEXT = booleanPreferencesKey("login_flow_cleartext") } } diff --git a/app/src/full/java/de/jeanlucmakiola/agendula/data/sync/RemoteListRepository.kt b/app/src/full/java/de/jeanlucmakiola/agendula/data/sync/RemoteListRepository.kt index 3fb8f29..7441ea1 100644 --- a/app/src/full/java/de/jeanlucmakiola/agendula/data/sync/RemoteListRepository.kt +++ b/app/src/full/java/de/jeanlucmakiola/agendula/data/sync/RemoteListRepository.kt @@ -14,6 +14,7 @@ import de.jeanlucmakiola.caldav.CollectionOutcome import de.jeanlucmakiola.caldav.CollectionSupport import de.jeanlucmakiola.caldav.DavCollectionAdmin import de.jeanlucmakiola.caldav.ResourceNames +import de.jeanlucmakiola.caldav.ServerTrust import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.async @@ -53,6 +54,7 @@ class RemoteListRepository @Inject constructor( private val accountState: AccountStateStore, private val resolver: ProviderResolver, private val push: PushStore, + private val trust: ServerTrustStore, @IoDispatcher private val io: CoroutineDispatcher, ) : RemoteLists { @@ -252,11 +254,11 @@ class RemoteListRepository @Inject constructor( val origin = account.principalUrl?.toHttpUrlOrNull() ?: return null val password = (credentials.get(account.id) as? CredentialStore.Secret.Present)?.value ?: return null - return DavCollectionAdmin(client(username, password, origin)) + return DavCollectionAdmin(client(username, password, origin, trust.get(account.id))) } - private fun client(username: String, password: String, origin: HttpUrl): OkHttpClient = - CalDavHttp.authenticated(USER_AGENT, username, password, origin) + private fun client(username: String, password: String, origin: HttpUrl, trust: ServerTrust): OkHttpClient = + CalDavHttp.authenticated(USER_AGENT, username, password, origin, trust) private suspend fun forgetPerListState(listId: Long) { val ids = setOf(listId) diff --git a/app/src/full/java/de/jeanlucmakiola/agendula/data/sync/ServerTrustStore.kt b/app/src/full/java/de/jeanlucmakiola/agendula/data/sync/ServerTrustStore.kt new file mode 100644 index 0000000..95efa49 --- /dev/null +++ b/app/src/full/java/de/jeanlucmakiola/agendula/data/sync/ServerTrustStore.kt @@ -0,0 +1,54 @@ +package de.jeanlucmakiola.agendula.data.sync + +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.booleanPreferencesKey +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringSetPreferencesKey +import de.jeanlucmakiola.agendula.data.di.SyncStateDataStore +import de.jeanlucmakiola.caldav.ServerTrust +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import javax.inject.Inject +import javax.inject.Singleton + +/** What each account's user agreed to trust: pinned certificates and plain http. */ +@Singleton +class ServerTrustStore @Inject constructor( + @SyncStateDataStore private val dataStore: DataStore, +) { + + suspend fun get(accountId: Long): ServerTrust = read(dataStore.data.first(), accountId) + + fun observe(accountId: Long): Flow = + dataStore.data.map { read(it, accountId) }.distinctUntilChanged() + + suspend fun put(accountId: Long, trust: ServerTrust) { + dataStore.edit { prefs -> + if (trust.pinnedCertificates.isEmpty()) { + prefs.remove(certificates(accountId)) + } else { + prefs[certificates(accountId)] = trust.pinnedCertificates + } + if (trust.allowCleartext) prefs[cleartext(accountId)] = true else prefs.remove(cleartext(accountId)) + } + } + + suspend fun forget(accountId: Long) { + dataStore.edit { prefs -> + prefs.remove(certificates(accountId)) + prefs.remove(cleartext(accountId)) + } + } + + private fun read(prefs: Preferences, accountId: Long) = ServerTrust( + pinnedCertificates = prefs[certificates(accountId)].orEmpty(), + allowCleartext = prefs[cleartext(accountId)] ?: false, + ) + + private fun certificates(accountId: Long) = stringSetPreferencesKey("server_trust_certs_$accountId") + + private fun cleartext(accountId: Long) = booleanPreferencesKey("server_trust_cleartext_$accountId") +} diff --git a/app/src/full/java/de/jeanlucmakiola/agendula/data/sync/SyncEngine.kt b/app/src/full/java/de/jeanlucmakiola/agendula/data/sync/SyncEngine.kt index 7b16605..8a0bd2f 100644 --- a/app/src/full/java/de/jeanlucmakiola/agendula/data/sync/SyncEngine.kt +++ b/app/src/full/java/de/jeanlucmakiola/agendula/data/sync/SyncEngine.kt @@ -33,6 +33,7 @@ class SyncEngine @Inject constructor( private val notices: SyncNoticeStore, private val availability: SyncAvailability, private val push: PushRegistrar, + private val trust: ServerTrustStore, @IoDispatcher private val io: CoroutineDispatcher, ) { @@ -90,7 +91,7 @@ class SyncEngine @Inject constructor( } } - val client = CalDavHttp.authenticated(USER_AGENT, username, password, origin) + val client = CalDavHttp.authenticated(USER_AGENT, username, password, origin, trust.get(account.id)) val subscriptions = push.subscriptionsByHref(account.id) val reports = syncCollections(account) { url -> CalendarCollection(client, url, pushRegistration = subscriptions[url.toString()]) diff --git a/app/src/full/java/de/jeanlucmakiola/agendula/data/sync/SyncFailure.kt b/app/src/full/java/de/jeanlucmakiola/agendula/data/sync/SyncFailure.kt index b114d32..14b1da0 100644 --- a/app/src/full/java/de/jeanlucmakiola/agendula/data/sync/SyncFailure.kt +++ b/app/src/full/java/de/jeanlucmakiola/agendula/data/sync/SyncFailure.kt @@ -49,6 +49,7 @@ data class SyncFailure(val kind: Kind, val httpCode: Int? = null) { private val TLS = listOf( "SSLHandshakeException", "SSLPeerUnverifiedException", + "UntrustedHostException", "CertPathValidatorException", "CertificateException", "SSLException", diff --git a/app/src/full/java/de/jeanlucmakiola/agendula/data/sync/push/PushRegistrar.kt b/app/src/full/java/de/jeanlucmakiola/agendula/data/sync/push/PushRegistrar.kt index b3faa64..c1eccc8 100644 --- a/app/src/full/java/de/jeanlucmakiola/agendula/data/sync/push/PushRegistrar.kt +++ b/app/src/full/java/de/jeanlucmakiola/agendula/data/sync/push/PushRegistrar.kt @@ -12,6 +12,7 @@ import dagger.hilt.android.qualifiers.ApplicationContext import de.jeanlucmakiola.agendula.data.di.IoDispatcher import de.jeanlucmakiola.agendula.data.sync.AccountStateStore import de.jeanlucmakiola.agendula.data.sync.CredentialStore +import de.jeanlucmakiola.agendula.data.sync.ServerTrustStore import de.jeanlucmakiola.agendula.data.sync.SyncAvailability import de.jeanlucmakiola.agendula.data.sync.SyncReport import de.jeanlucmakiola.agendula.data.tasks.room.AccountEntity @@ -48,6 +49,7 @@ class PushRegistrar @Inject constructor( private val credentials: CredentialStore, private val accountState: AccountStateStore, private val availability: SyncAvailability, + private val trust: ServerTrustStore, @IoDispatcher private val io: CoroutineDispatcher, ) { @@ -231,7 +233,7 @@ class PushRegistrar @Inject constructor( val username = account.username ?: return null val origin = account.principalUrl?.toHttpUrlOrNull() ?: return null val password = (credentials.get(account.id) as? CredentialStore.Secret.Present)?.value ?: return null - return CalDavHttp.authenticated(USER_AGENT, username, password, origin) + return CalDavHttp.authenticated(USER_AGENT, username, password, origin, trust.get(account.id)) .newBuilder() .callTimeout(CALL_TIMEOUT_SECONDS, TimeUnit.SECONDS) .build() diff --git a/app/src/full/java/de/jeanlucmakiola/agendula/ui/accounts/AccountDetailScreen.kt b/app/src/full/java/de/jeanlucmakiola/agendula/ui/accounts/AccountDetailScreen.kt index cf8e4dd..1d04595 100644 --- a/app/src/full/java/de/jeanlucmakiola/agendula/ui/accounts/AccountDetailScreen.kt +++ b/app/src/full/java/de/jeanlucmakiola/agendula/ui/accounts/AccountDetailScreen.kt @@ -16,9 +16,11 @@ import androidx.compose.material.icons.rounded.Bolt import androidx.compose.material.icons.rounded.CloudSync import androidx.compose.material.icons.rounded.DeleteOutline import androidx.compose.material.icons.rounded.Edit +import androidx.compose.material.icons.rounded.GppMaybe import androidx.compose.material.icons.rounded.History import androidx.compose.material.icons.rounded.HistoryToggleOff import androidx.compose.material.icons.rounded.Inventory2 +import androidx.compose.material.icons.rounded.LockOpen import androidx.compose.material.icons.rounded.Refresh import androidx.compose.material.icons.rounded.SyncProblem import androidx.compose.material3.Button @@ -50,6 +52,8 @@ import de.jeanlucmakiola.agendula.data.tasks.room.AccountEntity import de.jeanlucmakiola.agendula.ui.accounts.add.message import de.jeanlucmakiola.agendula.ui.common.NameField import de.jeanlucmakiola.agendula.ui.common.OnResume +import de.jeanlucmakiola.caldav.ServerCertificate +import de.jeanlucmakiola.caldav.ServerTrust import de.jeanlucmakiola.floret.components.CollapsingScaffold import de.jeanlucmakiola.floret.components.FullScreenPicker import de.jeanlucmakiola.floret.components.GroupedHint @@ -79,6 +83,8 @@ internal fun AccountDetailScreen( val accounts by viewModel.accounts.collectAsStateWithLifecycle() val usable by viewModel.accountsUsable.collectAsStateWithLifecycle() val push by viewModel.push.collectAsStateWithLifecycle() + val trust by remember(accountId) { viewModel.trust(accountId) } + .collectAsStateWithLifecycle(ServerTrust.DEFAULT) OnResume(viewModel::refreshPush) val row = accounts?.firstOrNull { it.account.id == accountId } @@ -130,6 +136,28 @@ internal fun AccountDetailScreen( leading = { Icon(Icons.Rounded.History, contentDescription = null) }, ) } + if (trust.allowCleartext) { + add { position -> + GroupedRow( + title = stringResource(R.string.accounts_connection), + summary = stringResource(R.string.accounts_connection_cleartext), + position = position, + leading = { Icon(Icons.Rounded.LockOpen, contentDescription = null) }, + ) + } + } + if (trust.pinnedCertificates.isNotEmpty()) { + add { position -> + GroupedRow( + title = stringResource(R.string.accounts_trusted_certificate), + summary = trust.pinnedCertificates.sorted().joinToString("\n") { + ServerCertificate.formatFingerprint(it.take(PINNED_PREFIX_CHARS)) + ":\u2026" + }, + position = position, + leading = { Icon(Icons.Rounded.GppMaybe, contentDescription = null) }, + ) + } + } if (!usable) return@buildList push?.let { pushState -> add { position -> @@ -533,3 +561,6 @@ private fun AccountNamePicker( GroupedHint(stringResource(R.string.accounts_name_hint, defaultTitle)) } } + +/** The first eight bytes of a pinned fingerprint, enough to compare at a glance. */ +private const val PINNED_PREFIX_CHARS = 16 diff --git a/app/src/full/java/de/jeanlucmakiola/agendula/ui/accounts/AccountsViewModel.kt b/app/src/full/java/de/jeanlucmakiola/agendula/ui/accounts/AccountsViewModel.kt index 6ca0d60..ff6c475 100644 --- a/app/src/full/java/de/jeanlucmakiola/agendula/ui/accounts/AccountsViewModel.kt +++ b/app/src/full/java/de/jeanlucmakiola/agendula/ui/accounts/AccountsViewModel.kt @@ -8,6 +8,7 @@ import de.jeanlucmakiola.agendula.data.prefs.DEFAULT_SYNC_INTERVAL_MINUTES import de.jeanlucmakiola.agendula.data.prefs.SettingsPrefs import de.jeanlucmakiola.agendula.data.sync.AccountRepository import de.jeanlucmakiola.agendula.data.sync.AccountStateStore +import de.jeanlucmakiola.agendula.data.sync.ServerTrustStore import de.jeanlucmakiola.agendula.data.sync.SyncAvailability import de.jeanlucmakiola.agendula.data.sync.SyncNotice import de.jeanlucmakiola.agendula.data.sync.SyncNoticeStore @@ -30,6 +31,7 @@ import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import de.jeanlucmakiola.caldav.CalDavDiscovery +import de.jeanlucmakiola.caldav.ServerTrust import de.jeanlucmakiola.caldav.TaskCollection import okhttp3.HttpUrl import javax.inject.Inject @@ -43,6 +45,7 @@ class AccountsViewModel @Inject constructor( private val settings: SettingsPrefs, private val distributors: PushDistributors, private val pushRegistrar: PushRegistrar, + private val serverTrust: ServerTrustStore, pushStore: PushStore, availability: SyncAvailability, @IoDispatcher io: CoroutineDispatcher, @@ -252,6 +255,9 @@ class AccountsViewModel @Inject constructor( viewModelScope.launch { repository.setLabel(account.id, label) } } + /** What the user agreed to trust for [accountId] beyond the platform's defaults. */ + fun trust(accountId: Long): Flow = serverTrust.observe(accountId) + /** The user has read what the last sync changed. */ fun dismissNotices(account: AccountEntity) { viewModelScope.launch { notices.dismiss(account.id) } diff --git a/app/src/full/java/de/jeanlucmakiola/agendula/ui/accounts/add/AddAccountScreen.kt b/app/src/full/java/de/jeanlucmakiola/agendula/ui/accounts/add/AddAccountScreen.kt index 7b3c390..4b96efb 100644 --- a/app/src/full/java/de/jeanlucmakiola/agendula/ui/accounts/add/AddAccountScreen.kt +++ b/app/src/full/java/de/jeanlucmakiola/agendula/ui/accounts/add/AddAccountScreen.kt @@ -17,7 +17,9 @@ import androidx.compose.material.icons.automirrored.rounded.ArrowBack import androidx.compose.material.icons.rounded.Checklist import androidx.compose.material.icons.rounded.CloudOff import androidx.compose.material.icons.rounded.CloudSync +import androidx.compose.material.icons.rounded.GppMaybe import androidx.compose.material.icons.rounded.Lock +import androidx.compose.material.icons.rounded.LockOpen import androidx.compose.material.icons.rounded.OpenInBrowser import androidx.compose.material.icons.rounded.Warning import androidx.compose.material3.Icon @@ -179,6 +181,8 @@ internal fun AddAccountScreen( is AddAccountStep.Working -> WorkingStep(step) is AddAccountStep.EnterCredentials -> CredentialsStep(step, viewModel) is AddAccountStep.ConfirmBrowser -> ConfirmBrowserStep(step) + is AddAccountStep.ConfirmCleartext -> ConfirmCleartextStep(step) + is AddAccountStep.TrustCertificate -> TrustCertificateStep(step) is AddAccountStep.WaitingForBrowser -> BrowserStep(step) is AddAccountStep.ChooseLists -> ListsStep(step, viewModel) is AddAccountStep.Summary -> SummaryStep(step) @@ -228,7 +232,11 @@ private fun AddAccountStep.position(hasSetup: Boolean): Int? { return when (this) { is AddAccountStep.ChooseProvider -> 1 is AddAccountStep.PrepareAccess -> 2 - is AddAccountStep.EnterAddress -> 2 + errand + // Both are about reaching the server, so they keep the address's slot. + is AddAccountStep.EnterAddress, + is AddAccountStep.ConfirmCleartext, + is AddAccountStep.TrustCertificate, + -> 2 + errand // One slot, because only one of the three ever happens: a server either // hands the sign-in to a browser — asking first, where the URL needs // confirming — or asks for a password. @@ -272,6 +280,8 @@ private fun StepHero(state: AddAccountUiState) { // The warning is the screen, so it is the mark too — the browser icon // would say "this is going fine", which is the opposite of the point. state.step is AddAccountStep.ConfirmBrowser -> Icons.Rounded.Warning + state.step is AddAccountStep.ConfirmCleartext -> Icons.Rounded.LockOpen + state.step is AddAccountStep.TrustCertificate -> Icons.Rounded.GppMaybe state.step is AddAccountStep.WaitingForBrowser -> Icons.Rounded.OpenInBrowser state.step is AddAccountStep.ChooseLists -> Icons.Rounded.Checklist else -> Icons.Rounded.CloudSync @@ -311,6 +321,10 @@ private fun StepTitle(step: AddAccountStep) { R.string.add_account_credentials_title to R.string.add_account_credentials_body is AddAccountStep.ConfirmBrowser -> R.string.add_account_browser_confirm_title to R.string.add_account_browser_confirm_body + is AddAccountStep.ConfirmCleartext -> + R.string.add_account_cleartext_title to R.string.add_account_cleartext_body + is AddAccountStep.TrustCertificate -> + R.string.add_account_certificate_title to R.string.add_account_certificate_body is AddAccountStep.ChooseLists -> R.string.add_account_lists_title to R.string.add_account_lists_body is AddAccountStep.Summary -> @@ -412,6 +426,26 @@ private fun ColumnScope.StepActions(state: AddAccountUiState, viewModel: AddAcco ) { Text(stringResource(R.string.add_account_browser_use_password)) } } + // Plain http stays a deliberate choice: going forward is primary, as it + // is for the browser warning, and changing the address is right below. + is AddAccountStep.ConfirmCleartext -> { + PrimaryAction( + label = stringResource(R.string.add_account_cleartext_connect), + enabled = true, + onClick = viewModel::onCleartextConfirmed, + ) + TextButton( + onClick = { viewModel.onBackWithin() }, + modifier = Modifier.fillMaxWidth(), + ) { Text(stringResource(R.string.add_account_change_address)) } + } + + is AddAccountStep.TrustCertificate -> PrimaryAction( + label = stringResource(R.string.add_account_certificate_trust), + enabled = true, + onClick = viewModel::onCertificateTrusted, + ) + is AddAccountStep.WaitingForBrowser -> { // A failed flow is retried by default. With no browser a retry // cannot help; while the server throttles or is in maintenance an diff --git a/app/src/full/java/de/jeanlucmakiola/agendula/ui/accounts/add/AddAccountText.kt b/app/src/full/java/de/jeanlucmakiola/agendula/ui/accounts/add/AddAccountText.kt index 48d83c8..ecabd75 100644 --- a/app/src/full/java/de/jeanlucmakiola/agendula/ui/accounts/add/AddAccountText.kt +++ b/app/src/full/java/de/jeanlucmakiola/agendula/ui/accounts/add/AddAccountText.kt @@ -21,6 +21,7 @@ internal val CalDavDiscovery.Outcome.Cause.message: Int CalDavDiscovery.Outcome.Cause.NOT_A_DAV_SERVER -> R.string.add_account_error_not_dav CalDavDiscovery.Outcome.Cause.NO_CALENDAR_SUPPORT -> R.string.add_account_error_no_calendar CalDavDiscovery.Outcome.Cause.UNREACHABLE -> R.string.add_account_error_unreachable + CalDavDiscovery.Outcome.Cause.TLS -> R.string.add_account_error_tls CalDavDiscovery.Outcome.Cause.INSECURE -> R.string.add_account_error_insecure CalDavDiscovery.Outcome.Cause.NO_CALENDARS -> R.string.add_account_error_no_calendars CalDavDiscovery.Outcome.Cause.SERVER_ERROR -> R.string.add_account_error_server diff --git a/app/src/full/java/de/jeanlucmakiola/agendula/ui/accounts/add/AddAccountViewModel.kt b/app/src/full/java/de/jeanlucmakiola/agendula/ui/accounts/add/AddAccountViewModel.kt index 53e4301..300de82 100644 --- a/app/src/full/java/de/jeanlucmakiola/agendula/ui/accounts/add/AddAccountViewModel.kt +++ b/app/src/full/java/de/jeanlucmakiola/agendula/ui/accounts/add/AddAccountViewModel.kt @@ -11,7 +11,9 @@ import de.jeanlucmakiola.agendula.data.sync.LoginFlowRecord import de.jeanlucmakiola.caldav.CalDavDiscovery import de.jeanlucmakiola.caldav.CalDavProvider import de.jeanlucmakiola.caldav.NextcloudLoginFlow +import de.jeanlucmakiola.caldav.ServerCertificate import de.jeanlucmakiola.caldav.ServerQuirk +import de.jeanlucmakiola.caldav.ServerTrust import de.jeanlucmakiola.caldav.ServiceDiscovery import de.jeanlucmakiola.caldav.TaskCollection import de.jeanlucmakiola.agendula.data.di.ApplicationScope @@ -116,6 +118,12 @@ sealed interface AddAccountStep { val insecure: Boolean = false, ) : AddAccountStep + /** The typed address is plain http; the user has to accept that before anything is sent. */ + data class ConfirmCleartext(val address: String) : AddAccountStep + + /** The server's certificate is not trusted by the platform; the user may pin it. */ + data class TrustCertificate(val certificate: ServerCertificate) : AddAccountStep + /** * The browser has the flow. We poll until the user approves, and offer an * explicit way out — Custom Tabs return **no result** when dismissed, and @@ -327,6 +335,29 @@ class AddAccountViewModel @Inject constructor( /** The flow the browser was last sent to, for a retry while it is still open. */ private var browserFlow: NextcloudLoginFlow.Flow? = null + /** What the user agreed to trust in this flow, handed to every gateway call. */ + private var trust: ServerTrust + get() = ServerTrust( + pinnedCertificates = handle.get>(KEY_TRUSTED_CERTIFICATES).orEmpty().toSet(), + allowCleartext = handle[KEY_CLEARTEXT] ?: false, + ) + set(value) { + handle[KEY_TRUSTED_CERTIFICATES] = ArrayList(value.pinnedCertificates) + handle[KEY_CLEARTEXT] = value.allowCleartext + } + + /** The address plain http was accepted for; another address asks again. */ + private var cleartextAddress: String? + get() = handle[KEY_CLEARTEXT_ADDRESS] + set(value) { handle[KEY_CLEARTEXT_ADDRESS] = value } + + /** Loading a re-authenticated account's stored trust, which discovery waits for. */ + private var trustLoad: Job? = null + + /** What [AddAccountStep.TrustCertificate] re-runs once trusted, and where Back goes. */ + private var trustRetry: (() -> Unit)? = null + private var trustDeclined: AddAccountStep? = null + /** Which hosts asked for credentials, for the cross-domain diagnostic. */ private var hostsNeedingAuth: List get() = handle.get>(KEY_HOSTS_NEEDING_AUTH).orEmpty() @@ -424,6 +455,14 @@ class AddAccountViewModel @Inject constructor( reauthenticating = true, ) } + // Starts from what the account already trusts, so a renewed certificate + // is the only thing asked about again. + val address = addressInput + trustLoad = viewModelScope.launch { + val stored = runCatching { repository.trustFor(accountId) }.getOrDefault(ServerTrust.DEFAULT) + trust = stored + if (stored.allowCleartext) cleartextAddress = address + } } /** The errand is read; on to the address. */ @@ -482,6 +521,9 @@ class AddAccountViewModel @Inject constructor( updateState { it.copy(originMismatch = null) } typedInput = input addressInput = input + trustRetry = null + trustDeclined = null + if (input != cleartextAddress) trust = trust.copy(allowCleartext = false) val quirk = ServerQuirk.forInput(input) if (quirk?.isFatal == true) { @@ -498,10 +540,15 @@ class AddAccountViewModel @Inject constructor( } serverRoot = ServiceDiscovery.serverRootFor(input) + discoverAddress() + } + /** Discovery for the submitted address, without credentials. */ + private fun discoverAddress() { working(AddAccountMessage.Progress.Discovering) viewModelScope.launch { - when (val outcome = gateway.discover(typedInput)) { + trustLoad?.join() + when (val outcome = gateway.discover(typedInput, trust = trust)) { is CalDavDiscovery.Outcome.Found -> onDiscovered(outcome) is CalDavDiscovery.Outcome.NeedsAuthentication -> { @@ -517,11 +564,51 @@ class AddAccountViewModel @Inject constructor( is CalDavDiscovery.Outcome.NotCalDav -> backToAddressOrServer(outcome.cause) - is CalDavDiscovery.Outcome.Failed -> backToAddressOrServer(outcome.cause) + is CalDavDiscovery.Outcome.Failed -> if (needsCleartextConsent(outcome.cause)) { + updateState { it.copy(step = AddAccountStep.ConfirmCleartext(typedInput)) } + } else { + backToAddressOrServer(outcome.cause) + } + + is CalDavDiscovery.Outcome.UntrustedCertificate -> + askToTrust(outcome.certificate, back = addressStep(typedInput)) { discoverAddress() } } } } + /** A typed http:// address the user has not yet accepted as unencrypted. */ + private fun needsCleartextConsent(cause: CalDavDiscovery.Outcome.Cause): Boolean = + cause == CalDavDiscovery.Outcome.Cause.INSECURE && !trust.allowCleartext && + typedInput.toHttpUrlOrNull()?.isHttps == false + + /** The warning is read and plain http is wanted anyway. */ + fun onCleartextConfirmed() { + if (current.step !is AddAccountStep.ConfirmCleartext) return + cleartextAddress = typedInput + trust = trust.copy(allowCleartext = true) + discoverAddress() + } + + /** The fingerprint is checked; pin it and re-run what it stopped. */ + fun onCertificateTrusted() { + val step = current.step as? AddAccountStep.TrustCertificate ?: return + val retry = trustRetry ?: return + trust = trust.withCertificate(step.certificate.fingerprint) + trustRetry = null + trustDeclined = null + retry() + } + + private fun askToTrust(certificate: ServerCertificate, back: AddAccountStep, retry: () -> Unit) { + // Already pinned and still refused: asking again could only loop. + if (certificate.fingerprint in trust.pinnedCertificates) { + return backToAddress(CalDavDiscovery.Outcome.Cause.TLS) + } + trustRetry = retry + trustDeclined = back + updateState { it.copy(step = AddAccountStep.TrustCertificate(certificate), openInBrowser = null) } + } + /** * An email address whose domain has no CalDAV server goes straight to the * server field rather than an error with nowhere to go; the email stays as @@ -562,7 +649,10 @@ class AddAccountViewModel @Inject constructor( if (step.username.isBlank() || step.password.isEmpty()) return username = step.username.trim() appPassword = step.password + discoverWithCredentials() + } + private fun discoverWithCredentials() { working(AddAccountMessage.Progress.SigningIn) viewModelScope.launch { // ⚠️ An error, not a silent unauthenticated retry. serverRootFor @@ -573,7 +663,7 @@ class AddAccountViewModel @Inject constructor( val root = serverRoot ?: return@launch backToAddress(CalDavDiscovery.Outcome.Cause.NOT_AN_ADDRESS) val credentials = CalDavGateway.Credentials(username, appPassword, root) - when (val outcome = gateway.discover(typedInput, credentials)) { + when (val outcome = gateway.discover(typedInput, credentials, trust)) { is CalDavDiscovery.Outcome.Found -> onDiscovered(outcome) // A second rejection is the point at which naming the provider's @@ -595,6 +685,10 @@ class AddAccountViewModel @Inject constructor( is CalDavDiscovery.Outcome.NotCalDav -> backToAddressOrServer(outcome.cause) is CalDavDiscovery.Outcome.Failed -> backToAddressOrServer(outcome.cause) + is CalDavDiscovery.Outcome.UntrustedCertificate -> askToTrust( + outcome.certificate, + back = AddAccountStep.EnterCredentials(username = username), + ) { discoverWithCredentials() } } } } @@ -613,7 +707,7 @@ class AddAccountViewModel @Inject constructor( /** Step 3b → the browser flow, if this looks like a Nextcloud. */ private suspend fun offerSignIn() { val root = serverRoot ?: return backToAddress(CalDavDiscovery.Outcome.Cause.NOT_AN_ADDRESS) - val flow = gateway.startLoginFlow(root) + val flow = gateway.startLoginFlow(root, trust) if (flow == null) { // Not a Nextcloud, or its login flow is unavailable. Ask for a @@ -674,7 +768,7 @@ class AddAccountViewModel @Inject constructor( */ private suspend fun launchBrowser(flow: NextcloudLoginFlow.Flow) { browserFlow = flow - runCatching { pendingFlow.remember(flow) } + runCatching { pendingFlow.remember(flow, trust) } updateState { it.copy( step = AddAccountStep.WaitingForBrowser(hostMismatch = flow.hostMismatch), @@ -703,7 +797,7 @@ class AddAccountViewModel @Inject constructor( // later poll can only report expiry. Bounded by the gateway's // own per-call budget, and the delay above stays cancellable, so // the loop still stops promptly. - val result = withContext(NonCancellable) { gateway.pollLoginFlow(flow) } + val result = withContext(NonCancellable) { gateway.pollLoginFlow(flow, trust) } when (result) { is NextcloudLoginFlow.PollResult.Approved -> { // ⚠️ Uncancellable. The 200 that carried this password is @@ -742,47 +836,7 @@ class AddAccountViewModel @Inject constructor( result.hostMismatch?.let { mismatch -> updateState { it.copy(originMismatch = mismatch) } } - working(AddAccountMessage.Progress.ReadingLists) - val outcome = gateway.discover( - // The server the credentials were *issued by*, not the - // host the user typed — the two differ in exactly the - // host-mismatch case this flow already models, and - // getting it wrong burns the one-shot app password. - target = result.credentials.server.toString(), - credentials = CalDavGateway.Credentials( - username, - appPassword, - result.credentials.server, - ), - ) - when (outcome) { - is CalDavDiscovery.Outcome.Found -> onDiscovered(outcome) - // ⚠️ Forward what actually happened. Reporting every - // outcome as NO_CALENDARS tells someone whose server - // named an unresolvable host that their account holds - // no task lists, which is both wrong and unactionable. - is CalDavDiscovery.Outcome.Failed -> backToAddress(outcome.cause) - is CalDavDiscovery.Outcome.NotCalDav -> backToAddress(outcome.cause) - // ⚠️ These two carry no Cause, so they used to land - // on "signed in, but no task lists" — said of a - // credential the server had just rejected. A 401 here - // is either a home set outside the domain the - // credential is scoped to, which retrying cannot fix - // and which only mints a second password, or the - // server having a moment. - is CalDavDiscovery.Outcome.NeedsAuthentication -> { - hostsNeedingAuth = outcome.hosts - val stranger = outsideCredentialScope(outcome.hosts) - if (stranger != null) { - fatal(AddAccountMessage.OutsideCredentialScope(stranger)) - } else { - backToAddress(CalDavDiscovery.Outcome.Cause.SERVER_ERROR) - } - } - - CalDavDiscovery.Outcome.Unauthenticated -> - backToAddress(CalDavDiscovery.Outcome.Cause.SERVER_ERROR) - } + discoverApproved(result.credentials.server) return@launch } @@ -815,6 +869,53 @@ class AddAccountViewModel @Inject constructor( } } + /** Discovery with a password the login flow just minted, at the server that issued it. */ + private suspend fun discoverApproved(server: HttpUrl) { + working(AddAccountMessage.Progress.ReadingLists) + val outcome = gateway.discover( + // The server the credentials were *issued by*, not the + // host the user typed — the two differ in exactly the + // host-mismatch case this flow already models, and + // getting it wrong burns the one-shot app password. + target = server.toString(), + credentials = CalDavGateway.Credentials(username, appPassword, server), + trust = trust, + ) + when (outcome) { + is CalDavDiscovery.Outcome.Found -> onDiscovered(outcome) + // ⚠️ Forward what actually happened. Reporting every + // outcome as NO_CALENDARS tells someone whose server + // named an unresolvable host that their account holds + // no task lists, which is both wrong and unactionable. + is CalDavDiscovery.Outcome.Failed -> backToAddress(outcome.cause) + is CalDavDiscovery.Outcome.NotCalDav -> backToAddress(outcome.cause) + // ⚠️ These two carry no Cause, so they used to land + // on "signed in, but no task lists" — said of a + // credential the server had just rejected. A 401 here + // is either a home set outside the domain the + // credential is scoped to, which retrying cannot fix + // and which only mints a second password, or the + // server having a moment. + is CalDavDiscovery.Outcome.NeedsAuthentication -> { + hostsNeedingAuth = outcome.hosts + val stranger = outsideCredentialScope(outcome.hosts) + if (stranger != null) { + fatal(AddAccountMessage.OutsideCredentialScope(stranger)) + } else { + backToAddress(CalDavDiscovery.Outcome.Cause.SERVER_ERROR) + } + } + + CalDavDiscovery.Outcome.Unauthenticated -> + backToAddress(CalDavDiscovery.Outcome.Cause.SERVER_ERROR) + + is CalDavDiscovery.Outcome.UntrustedCertificate -> + askToTrust(outcome.certificate, back = addressStep(typedInput)) { + viewModelScope.launch { discoverApproved(server) } + } + } + } + /** * Another go after the browser sign-in failed. * @@ -898,6 +999,7 @@ class AddAccountViewModel @Inject constructor( found = discovered, selected = chosen, reauthenticating = reauthAccountId, + trust = trust, ) }.getOrElse { // A constraint violation or an IO failure in DataStore must not @@ -1004,6 +1106,19 @@ class AddAccountViewModel @Inject constructor( true } + is AddAccountStep.ConfirmCleartext -> { + updateState { it.copy(step = addressStep(typedInput), fatal = null) } + true + } + + is AddAccountStep.TrustCertificate -> { + val back = trustDeclined ?: addressStep(typedInput) + trustRetry = null + trustDeclined = null + updateState { it.copy(step = back, fatal = null) } + true + } + else -> false } @@ -1045,6 +1160,12 @@ class AddAccountViewModel @Inject constructor( reauthAccountId = null suggestedUsername = "" fallbackEmail = null + trustLoad?.cancel() + trustLoad = null + trust = ServerTrust.DEFAULT + cleartextAddress = null + trustRetry = null + trustDeclined = null current = AddAccountUiState() } @@ -1068,9 +1189,10 @@ class AddAccountViewModel @Inject constructor( private fun discardMintedPassword() { val credentials = minted ?: return minted = null + val trust = trust // The scope has no exception handler, so anything escaping here takes the // app down — for a courtesy call whose failures are deliberately silent. - appScope.launch { runCatching { gateway.revokeIssuedAppPassword(credentials) } } + appScope.launch { runCatching { gateway.revokeIssuedAppPassword(credentials, trust) } } } // -------------------------------------------------------- saved state @@ -1095,6 +1217,8 @@ class AddAccountViewModel @Inject constructor( // what deals with that on the next open. Recording them as // themselves would only mean deciding the same thing twice. is AddAccountStep.ConfirmBrowser, is AddAccountStep.WaitingForBrowser -> STEP_ADDRESS + // Both re-ask on the next submit, which is where a restore puts them. + is AddAccountStep.ConfirmCleartext, is AddAccountStep.TrustCertificate -> STEP_ADDRESS is AddAccountStep.Working -> STEP_WORKING is AddAccountStep.ChooseLists -> STEP_LISTS is AddAccountStep.Summary -> STEP_SUMMARY @@ -1200,7 +1324,7 @@ class AddAccountViewModel @Inject constructor( ) viewModelScope.launch { val credentials = CalDavGateway.Credentials(username, appPassword, root) - when (val outcome = gateway.discover(target, credentials)) { + when (val outcome = gateway.discover(target, credentials, trust)) { is CalDavDiscovery.Outcome.Found -> onDiscovered(outcome, selection) is CalDavDiscovery.Outcome.NotCalDav -> backToAddressOrServer(outcome.cause) is CalDavDiscovery.Outcome.Failed -> backToAddressOrServer(outcome.cause) @@ -1211,6 +1335,8 @@ class AddAccountViewModel @Inject constructor( credentialsRejected() } CalDavDiscovery.Outcome.Unauthenticated -> credentialsRejected() + is CalDavDiscovery.Outcome.UntrustedCertificate -> + askToTrust(outcome.certificate, back = addressStep(addressInput)) { resumeDiscovery(hasSetup) } } } } @@ -1438,6 +1564,9 @@ class AddAccountViewModel @Inject constructor( const val KEY_REAUTH_ACCOUNT = "add_account.reauth_account" const val KEY_SUGGESTED_USERNAME = "add_account.suggested_username" const val KEY_FALLBACK_EMAIL = "add_account.fallback_email" + const val KEY_TRUSTED_CERTIFICATES = "add_account.trusted_certificates" + const val KEY_CLEARTEXT = "add_account.cleartext" + const val KEY_CLEARTEXT_ADDRESS = "add_account.cleartext_address" /** What sends an email to the server field: no CalDAV on its domain, or nothing answering for it. */ val NO_SERVER_FOUND = setOf( diff --git a/app/src/full/java/de/jeanlucmakiola/agendula/ui/accounts/add/ServerTrustSteps.kt b/app/src/full/java/de/jeanlucmakiola/agendula/ui/accounts/add/ServerTrustSteps.kt new file mode 100644 index 0000000..f65fc92 --- /dev/null +++ b/app/src/full/java/de/jeanlucmakiola/agendula/ui/accounts/add/ServerTrustSteps.kt @@ -0,0 +1,76 @@ +package de.jeanlucmakiola.agendula.ui.accounts.add + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Badge +import androidx.compose.material.icons.rounded.Event +import androidx.compose.material.icons.rounded.Fingerprint +import androidx.compose.material.icons.rounded.LockOpen +import androidx.compose.material.icons.rounded.Verified +import androidx.compose.material3.Icon +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.unit.dp +import de.jeanlucmakiola.agendula.R +import de.jeanlucmakiola.floret.components.GroupedRow +import de.jeanlucmakiola.floret.components.Position +import de.jeanlucmakiola.floret.components.positionOf +import java.text.DateFormat +import java.util.Date + +/** The typed address is plain http: what that exposes, before anything is sent. */ +@Composable +internal fun ConfirmCleartextStep(step: AddAccountStep.ConfirmCleartext) { + GroupedRow( + title = step.address, + position = Position.Alone, + leading = { Icon(Icons.Rounded.LockOpen, contentDescription = null) }, + ) + Spacer(Modifier.height(16.dp)) + QuirkNote(text = stringResource(R.string.add_account_cleartext_note)) +} + +/** + * A certificate the phone does not trust, laid out for comparing against the + * server's own. The fingerprint is the part that matters, so it comes first and + * can be selected. + */ +@Composable +internal fun TrustCertificateStep(step: AddAccountStep.TrustCertificate) { + val certificate = step.certificate + val locale = LocalConfiguration.current.locales[0] + val expires = DateFormat.getDateInstance(DateFormat.MEDIUM, locale).format(Date(certificate.notAfterMillis)) + val rows = listOf( + Triple(Icons.Rounded.Fingerprint, R.string.add_account_certificate_fingerprint, certificate.displayFingerprint), + Triple(Icons.Rounded.Badge, R.string.add_account_certificate_subject, certificate.subject), + Triple(Icons.Rounded.Verified, R.string.add_account_certificate_issuer, certificate.issuer), + Triple(Icons.Rounded.Event, R.string.add_account_certificate_expires, expires), + ) + SelectionContainer { + Column { + rows.forEachIndexed { index, (icon, title, value) -> + GroupedRow( + title = AnnotatedString(stringResource(title)), + summary = if (index == 0) { + buildAnnotatedString { withStyle(SpanStyle(fontFamily = FontFamily.Monospace)) { append(value) } } + } else { + AnnotatedString(value) + }, + position = positionOf(index, rows.size), + leading = { Icon(icon, contentDescription = null) }, + ) + } + } + } + Spacer(Modifier.height(16.dp)) +} diff --git a/app/src/full/res/xml/network_security_config.xml b/app/src/full/res/xml/network_security_config.xml index f8684a7..15e9a90 100644 --- a/app/src/full/res/xml/network_security_config.xml +++ b/app/src/full/res/xml/network_security_config.xml @@ -5,11 +5,11 @@ default trust anchors. That breaks exactly the self-hosting audience this app is for, so they are added back here. - Cleartext stays off. It has been the default since API 28, and CalDavDiscovery - refuses a typed http:// address for the same reason: credentials are never - sent over an unencrypted connection. Any escape hatch has to be a narrow, - warned, per-account opt-in — Play's User Data policy requires modern - cryptography in transit — and this file is not the place for it. + Cleartext is permitted here and gated in code instead, per account: every + client CalDavHttp builds leaves ConnectionSpec.CLEARTEXT out unless the user + opted that account into plain http behind an explicit warning, so OkHttp + itself refuses an http:// URL for everyone else. This file cannot express a + per-account choice, only a per-host one, and the hosts are the user's. ⚠️ KNOWN TRADE-OFF, and it is the widest form of this. base-config applies to *all* traffic, so any CA in the user store — a corporate MDM profile, a "free @@ -25,7 +25,7 @@ sit alongside it. --> - + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 81cd474..96cac40 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -688,6 +688,9 @@ Last sync Name How this account is shown in Agendula. Leave it empty to use “%1$s”. + Connection + Unencrypted (http) + Trusted certificate CalDAV account Last sync didn\u2019t finish Sync failed \u2014 the server didn\u2019t accept the password @@ -737,6 +740,7 @@ That address is unencrypted. Agendula only sends your password over https. Signed in, but this account has no task lists. The server ran into a problem. Try again in a moment. + Couldn\u2019t set up a secure connection to that server. Looking for a CalDAV server Signing in Reading your task lists @@ -815,6 +819,20 @@ The server sent us to %1$s, but you typed %2$s. That usually means its overwrite.cli.url setting is wrong. The server reported its address as %1$s, which this device cannot reach, so %2$s was used instead. Its overwrite.cli.url or trusted_proxies setting is probably wrong. + Connect without encryption? + This address uses http, not https. Your password and your tasks would travel unencrypted, and anyone on the network between this phone and the server could read them. + That can be fine for a server you only reach over a VPN or your home network. Anywhere else, set up https on the server instead. + Connect anyway + Change the address + + Trust this certificate? + This server\u2019s certificate isn\u2019t signed by an authority your phone trusts. If you run this server yourself, compare the fingerprint below with your server\u2019s certificate before trusting it. + SHA-256 fingerprint + Issued to + Issued by + Valid until + Trust this certificate + Which lists should sync? Read-only Shared with you diff --git a/app/src/testFull/java/de/jeanlucmakiola/agendula/data/sync/ServerTrustStoreTest.kt b/app/src/testFull/java/de/jeanlucmakiola/agendula/data/sync/ServerTrustStoreTest.kt new file mode 100644 index 0000000..ab5911c --- /dev/null +++ b/app/src/testFull/java/de/jeanlucmakiola/agendula/data/sync/ServerTrustStoreTest.kt @@ -0,0 +1,57 @@ +package de.jeanlucmakiola.agendula.data.sync + +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.PreferenceDataStoreFactory +import androidx.datastore.preferences.core.Preferences +import com.google.common.truth.Truth.assertThat +import de.jeanlucmakiola.caldav.ServerTrust +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.io.TempDir +import java.io.File + +/** Per-account trust: pinned certificates and the plain-http opt-in. */ +class ServerTrustStoreTest { + + @TempDir lateinit var directory: File + + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Unconfined) + + private val dataStore: DataStore by lazy { + PreferenceDataStoreFactory.create(scope = scope) { File(directory, "trust.preferences_pb") } + } + + private val store by lazy { ServerTrustStore(dataStore) } + + @AfterEach fun tearDown() = scope.cancel() + + @Test + fun `an account nobody trusted anything for gets the platform's defaults`() = runTest { + assertThat(store.get(1)).isEqualTo(ServerTrust.DEFAULT) + } + + @Test + fun `what is put comes back, per account`() = runTest { + val trust = ServerTrust(pinnedCertificates = setOf("ab", "cd"), allowCleartext = true) + store.put(1, trust) + store.put(2, ServerTrust(pinnedCertificates = setOf("ef"))) + + assertThat(store.get(1)).isEqualTo(trust) + assertThat(store.get(2)).isEqualTo(ServerTrust(pinnedCertificates = setOf("ef"))) + } + + @Test + fun `forgetting one account leaves another alone`() = runTest { + store.put(1, ServerTrust(allowCleartext = true)) + store.put(2, ServerTrust(allowCleartext = true)) + store.forget(1) + + assertThat(store.get(1)).isEqualTo(ServerTrust.DEFAULT) + assertThat(store.get(2).allowCleartext).isTrue() + } +} diff --git a/app/src/testFull/java/de/jeanlucmakiola/agendula/ui/accounts/add/AddAccountViewModelTest.kt b/app/src/testFull/java/de/jeanlucmakiola/agendula/ui/accounts/add/AddAccountViewModelTest.kt index dd3d34c..17803ea 100644 --- a/app/src/testFull/java/de/jeanlucmakiola/agendula/ui/accounts/add/AddAccountViewModelTest.kt +++ b/app/src/testFull/java/de/jeanlucmakiola/agendula/ui/accounts/add/AddAccountViewModelTest.kt @@ -9,7 +9,9 @@ import de.jeanlucmakiola.agendula.data.sync.LoginFlowRecord import de.jeanlucmakiola.caldav.CalDavDiscovery import de.jeanlucmakiola.caldav.CalDavProvider import de.jeanlucmakiola.caldav.NextcloudLoginFlow +import de.jeanlucmakiola.caldav.ServerCertificate import de.jeanlucmakiola.caldav.ServerQuirk +import de.jeanlucmakiola.caldav.ServerTrust import de.jeanlucmakiola.caldav.TaskCollection import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -1409,6 +1411,166 @@ class AddAccountViewModelTest { } } + @Nested + inner class TrustingTheServer { + + private val certificate = ServerCertificate( + fingerprint = "ab".repeat(32), + subject = "CN=nas.home.arpa", + issuer = "CN=nas.home.arpa", + notAfterMillis = 0, + ) + + @Test + fun `a typed http address asks before anything is sent`() = runTest(dispatcher) { + gateway.discoveryOutcomes += CalDavDiscovery.Outcome.Failed( + CalDavDiscovery.Outcome.Cause.INSECURE, + "http", + ) + val vm = viewModel() + vm.enterAddress("http://nas.local:5232") + vm.onAddressSubmitted() + advanceUntilIdle() + + assertThat(vm.state.value.step) + .isEqualTo(AddAccountStep.ConfirmCleartext("http://nas.local:5232")) + + gateway.discoveryOutcomes += CalDavDiscovery.Outcome.NeedsAuthentication(emptyList()) + vm.onCleartextConfirmed() + advanceUntilIdle() + + assertThat(gateway.discoveries.last().trust.allowCleartext).isTrue() + assertThat(vm.state.value.step).isInstanceOf(AddAccountStep.EnterCredentials::class.java) + } + + @Test + fun `declining plain http goes back to the address`() = runTest(dispatcher) { + gateway.discoveryOutcomes += CalDavDiscovery.Outcome.Failed( + CalDavDiscovery.Outcome.Cause.INSECURE, + "http", + ) + val vm = viewModel() + vm.enterAddress("http://nas.local") + vm.onAddressSubmitted() + advanceUntilIdle() + + assertThat(vm.onBackWithin()).isTrue() + assertThat((vm.state.value.step as AddAccountStep.EnterAddress).input).isEqualTo("http://nas.local") + } + + @Test + fun `an https downgrade is still an error, not a question`() = runTest(dispatcher) { + gateway.discoveryOutcomes += CalDavDiscovery.Outcome.Failed( + CalDavDiscovery.Outcome.Cause.INSECURE, + "home set downgrades", + ) + val vm = viewModel() + vm.enterAddress("https://cloud.example.com") + vm.onAddressSubmitted() + advanceUntilIdle() + + assertThat((vm.state.value.step as AddAccountStep.EnterAddress).error) + .isEqualTo(CalDavDiscovery.Outcome.Cause.INSECURE) + } + + @Test + fun `a trusted certificate is pinned and the discovery re-run`() = runTest(dispatcher) { + gateway.discoveryOutcomes += CalDavDiscovery.Outcome.UntrustedCertificate(certificate) + val vm = viewModel() + vm.enterAddress("https://nas.home.arpa") + vm.onAddressSubmitted() + advanceUntilIdle() + + assertThat(vm.state.value.step).isEqualTo(AddAccountStep.TrustCertificate(certificate)) + + gateway.discoveryOutcomes += CalDavDiscovery.Outcome.NeedsAuthentication(emptyList()) + vm.onCertificateTrusted() + advanceUntilIdle() + + assertThat(gateway.discoveries.last().trust.pinnedCertificates) + .containsExactly(certificate.fingerprint) + assertThat(vm.state.value.step).isInstanceOf(AddAccountStep.EnterCredentials::class.java) + + // And it travels with the account. + gateway.discoveryOutcomes += found(collection("Tasks")) + vm.onUsernameChanged("me") + vm.onPasswordChanged("pw") + vm.onCredentialsSubmitted() + advanceUntilIdle() + assertThat(gateway.discoveries.last().trust.pinnedCertificates) + .containsExactly(certificate.fingerprint) + vm.onSave() + advanceUntilIdle() + assertThat(creator.savedTrust.single().pinnedCertificates).containsExactly(certificate.fingerprint) + } + + @Test + fun `a certificate asked about after sign-in returns to the password`() = runTest(dispatcher) { + gateway.discoveryOutcomes += CalDavDiscovery.Outcome.NeedsAuthentication(emptyList()) + gateway.loginFlow = null + val vm = viewModel() + vm.enterAddress("https://cloud.example.com/") + vm.onAddressSubmitted() + advanceUntilIdle() + + gateway.discoveryOutcomes += CalDavDiscovery.Outcome.UntrustedCertificate(certificate) + vm.onUsernameChanged("me") + vm.onPasswordChanged("pw") + vm.onCredentialsSubmitted() + advanceUntilIdle() + assertThat(vm.state.value.step).isInstanceOf(AddAccountStep.TrustCertificate::class.java) + + assertThat(vm.onBackWithin()).isTrue() + assertThat((vm.state.value.step as AddAccountStep.EnterCredentials).username).isEqualTo("me") + } + + @Test + fun `a certificate already pinned is not asked about again`() = runTest(dispatcher) { + gateway.discoveryOutcomes += CalDavDiscovery.Outcome.UntrustedCertificate(certificate) + gateway.discoveryOutcomes += CalDavDiscovery.Outcome.UntrustedCertificate(certificate) + val vm = viewModel() + vm.enterAddress("https://nas.home.arpa") + vm.onAddressSubmitted() + advanceUntilIdle() + vm.onCertificateTrusted() + advanceUntilIdle() + + assertThat((vm.state.value.step as AddAccountStep.EnterAddress).error) + .isEqualTo(CalDavDiscovery.Outcome.Cause.TLS) + } + + @Test + fun `signing in again starts from what the account trusts`() = runTest(dispatcher) { + val stored = ServerTrust(pinnedCertificates = setOf("cd".repeat(32)), allowCleartext = true) + creator.storedTrust[9] = stored + gateway.discoveryOutcomes += CalDavDiscovery.Outcome.NeedsAuthentication(emptyList()) + val vm = viewModel() + vm.startReauthentication(9, "http://nas.local/dav/principals/me/", "me") + advanceUntilIdle() + vm.onAddressSubmitted() + advanceUntilIdle() + + assertThat(gateway.discoveries.single().trust).isEqualTo(stored) + } + + @Test + fun `starting over forgets what was trusted`() = runTest(dispatcher) { + gateway.discoveryOutcomes += CalDavDiscovery.Outcome.UntrustedCertificate(certificate) + val vm = viewModel() + vm.enterAddress("https://nas.home.arpa") + vm.onAddressSubmitted() + advanceUntilIdle() + vm.onCertificateTrusted() + advanceUntilIdle() + vm.onStartOver() + + vm.enterAddress("https://nas.home.arpa") + vm.onAddressSubmitted() + advanceUntilIdle() + assertThat(gateway.discoveries.last().trust).isEqualTo(ServerTrust.DEFAULT) + } + } + /** * A ViewModel driven the way the screen drives it, up to the list step: * address → credentials → discovery → lists. Credentials are captured on @@ -1476,7 +1638,11 @@ class AddAccountViewModelTest { ) private class FakeGateway : CalDavGateway { - data class Call(val target: String, val credentials: CalDavGateway.Credentials?) + data class Call( + val target: String, + val credentials: CalDavGateway.Credentials?, + val trust: ServerTrust = ServerTrust.DEFAULT, + ) val discoveries = mutableListOf() val discoveryOutcomes = ArrayDeque() @@ -1486,8 +1652,9 @@ class AddAccountViewModelTest { override suspend fun discover( target: String, credentials: CalDavGateway.Credentials?, + trust: ServerTrust, ): CalDavDiscovery.Outcome { - discoveries += Call(target, credentials) + discoveries += Call(target, credentials, trust) return discoveryOutcomes.removeFirstOrNull() ?: CalDavDiscovery.Outcome.Failed( CalDavDiscovery.Outcome.Cause.SERVER_ERROR, @@ -1497,17 +1664,18 @@ class AddAccountViewModelTest { var loginFlowsStarted = 0 - override suspend fun startLoginFlow(server: HttpUrl): NextcloudLoginFlow.Flow? { + override suspend fun startLoginFlow(server: HttpUrl, trust: ServerTrust): NextcloudLoginFlow.Flow? { loginFlowsStarted++ return loginFlow } - override suspend fun pollLoginFlow(flow: NextcloudLoginFlow.Flow) = + override suspend fun pollLoginFlow(flow: NextcloudLoginFlow.Flow, trust: ServerTrust) = pollResults.removeFirstOrNull() ?: NextcloudLoginFlow.PollResult.Pending /** Revocation is best effort and the flow never depends on it. */ override suspend fun revokeAppPassword( credentials: CalDavGateway.Credentials, + trust: ServerTrust, ): Boolean = true val revoked = mutableListOf() @@ -1515,6 +1683,7 @@ class AddAccountViewModelTest { override suspend fun revokeIssuedAppPassword( credentials: CalDavGateway.Credentials, + trust: ServerTrust, ): Boolean { revoked += credentials return revokeSucceeds @@ -1526,7 +1695,7 @@ class AddAccountViewModelTest { val log = mutableListOf() var remembered: NextcloudLoginFlow.Flow? = null - override suspend fun remember(flow: NextcloudLoginFlow.Flow) { + override suspend fun remember(flow: NextcloudLoginFlow.Flow, trust: ServerTrust) { remembered = flow log += "remember" } @@ -1542,6 +1711,11 @@ class AddAccountViewModelTest { val reauthenticated = mutableListOf() var thrown: Throwable? = null var outcome: AccountRepository.Outcome? = null + val savedTrust = mutableListOf() + val storedTrust = mutableMapOf() + + override suspend fun trustFor(accountId: Long): ServerTrust = + storedTrust[accountId] ?: ServerTrust.DEFAULT override suspend fun create( displayName: String, @@ -1550,8 +1724,10 @@ class AddAccountViewModelTest { found: CalDavDiscovery.Outcome.Found, selected: Set, reauthenticating: Long?, + trust: ServerTrust, ): AccountRepository.Outcome { reauthenticated += reauthenticating + savedTrust += trust thrown?.let { throw it } outcome?.let { return it } created += displayName diff --git a/caldav/build.gradle.kts b/caldav/build.gradle.kts index e159e77..dcbe08d 100644 --- a/caldav/build.gradle.kts +++ b/caldav/build.gradle.kts @@ -32,5 +32,6 @@ dependencies { testImplementation(libs.xpp3) testImplementation(libs.junit4) testImplementation(libs.okhttp.mockwebserver) + testImplementation(libs.okhttp.tls) testImplementation(libs.truth) } diff --git a/caldav/src/main/kotlin/de/jeanlucmakiola/caldav/CalDavDiscovery.kt b/caldav/src/main/kotlin/de/jeanlucmakiola/caldav/CalDavDiscovery.kt index 0854440..4eb5869 100644 --- a/caldav/src/main/kotlin/de/jeanlucmakiola/caldav/CalDavDiscovery.kt +++ b/caldav/src/main/kotlin/de/jeanlucmakiola/caldav/CalDavDiscovery.kt @@ -9,6 +9,7 @@ import okhttp3.HttpUrl import okhttp3.HttpUrl.Companion.toHttpUrlOrNull import okhttp3.OkHttpClient import java.io.IOException +import javax.net.ssl.SSLException /** * RFC 6764 discovery: from what the user typed to the list of task collections. @@ -44,6 +45,8 @@ class CalDavDiscovery( val url: HttpUrl, val reason: String, val needsAuthentication: Boolean, + /** Set when the home set's host presented a certificate the user could trust. */ + val certificate: ServerCertificate? = null, ) sealed interface Outcome { @@ -85,6 +88,12 @@ class CalDavDiscovery( data class Failed(val cause: Cause, val detail: String) : Outcome + /** + * The server's certificate is not trusted by the platform. Not a + * failure either — the user may trust it and retry. + */ + data class UntrustedCertificate(val certificate: ServerCertificate) : Outcome + /** * Why discovery ended, in a form the UI can translate. * @@ -105,9 +114,12 @@ class CalDavDiscovery( /** Speaks WebDAV but not CalDAV — a file-sharing endpoint, say. */ NO_CALENDAR_SUPPORT, - /** Nothing answered: DNS, connection refused, TLS, timeout. */ + /** Nothing answered: DNS, connection refused, timeout. */ UNREACHABLE, + /** A secure connection could not be set up, for a reason the user cannot waive. */ + TLS, + /** The address, or where it redirects, is plain HTTP. */ INSECURE, @@ -128,12 +140,13 @@ class CalDavDiscovery( * means exactly "this is not a DAV server". Surfacing it as "HTTP 405 Method * Not Allowed" hands the user a status code where they needed a sentence. */ - private fun causeOf(error: Throwable): Outcome.Cause = when { + internal fun causeOf(error: Throwable): Outcome.Cause = when { error is HttpException -> when (error.code) { METHOD_NOT_ALLOWED, NOT_IMPLEMENTED, NOT_FOUND -> Outcome.Cause.NOT_A_DAV_SERVER else -> Outcome.Cause.SERVER_ERROR } - // Everything that never got an answer: DNS, refused, TLS, timeout. + error is SSLException -> Outcome.Cause.TLS + // Everything that never got an answer: DNS, refused, timeout. error is IOException -> Outcome.Cause.UNREACHABLE else -> Outcome.Cause.SERVER_ERROR } @@ -163,7 +176,11 @@ class CalDavDiscovery( var lastFailure: Outcome = Outcome.Failed(Outcome.Cause.UNREACHABLE, "no candidate answered") for (candidate in candidates) { when (val outcome = probe(candidate.url)) { - is Outcome.Found, is Outcome.NeedsAuthentication, Outcome.Unauthenticated -> return outcome + is Outcome.Found, + is Outcome.NeedsAuthentication, + Outcome.Unauthenticated, + is Outcome.UntrustedCertificate, + -> return outcome else -> lastFailure = outcome } } @@ -195,7 +212,7 @@ class CalDavDiscovery( // 401 is not a failure — iCloud and Zoho answer it from // /.well-known/caldav, which *is* the DAV root, and it is RFC-legal. if (isUnauthorized(error)) return Outcome.NeedsAuthentication(listOf(base.host)) - return Outcome.Failed(causeOf(error), error.message ?: error.toString()) + return failure(error) } // ⚠️ RFC 5397 §3: a 200 carrying means the @@ -258,7 +275,7 @@ class CalDavDiscovery( } homeSetResult.exceptionOrNull()?.let { error -> if (isUnauthorized(error)) return Outcome.NeedsAuthentication(listOf(principal.host)) - return Outcome.Failed(causeOf(error), error.message ?: error.toString()) + return failure(error) } if (homeSets.isEmpty()) { @@ -299,6 +316,7 @@ class CalDavDiscovery( url = homeSet, reason = it.message ?: it.toString(), needsAuthentication = isUnauthorized(it), + certificate = untrustedCertificateIn(it)?.let(ServerCertificate::of), ) }, ) @@ -309,8 +327,11 @@ class CalDavDiscovery( // lists" for what is actually an error. if (!anySucceeded) { val needAuth = failures.filter { it.needsAuthentication } + val certificate = failures.firstNotNullOfOrNull { it.certificate } return if (needAuth.isNotEmpty() && needAuth.size == failures.size) { Outcome.NeedsAuthentication(needAuth.map { it.url.host }.distinct()) + } else if (certificate != null) { + Outcome.UntrustedCertificate(certificate) } else { Outcome.Failed( Outcome.Cause.NO_CALENDARS, @@ -329,6 +350,10 @@ class CalDavDiscovery( ) } + private fun failure(error: Throwable): Outcome = + untrustedCertificateIn(error)?.let { Outcome.UntrustedCertificate(ServerCertificate.of(it)) } + ?: Outcome.Failed(causeOf(error), error.message ?: error.toString()) + private fun isUnauthorized(error: Throwable): Boolean = error is at.bitfire.dav4jvm.exception.UnauthorizedException diff --git a/caldav/src/main/kotlin/de/jeanlucmakiola/caldav/CalDavHttp.kt b/caldav/src/main/kotlin/de/jeanlucmakiola/caldav/CalDavHttp.kt index 0ff1ad1..c8ad5a6 100644 --- a/caldav/src/main/kotlin/de/jeanlucmakiola/caldav/CalDavHttp.kt +++ b/caldav/src/main/kotlin/de/jeanlucmakiola/caldav/CalDavHttp.kt @@ -1,6 +1,7 @@ package de.jeanlucmakiola.caldav import at.bitfire.dav4jvm.BasicDigestAuthHandler +import okhttp3.ConnectionSpec import okhttp3.HttpUrl import okhttp3.Interceptor import okhttp3.OkHttpClient @@ -8,7 +9,10 @@ import okhttp3.Response import okhttp3.ResponseBody.Companion.asResponseBody import okio.GzipSource import okio.buffer +import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.TimeUnit +import javax.net.ssl.SSLContext +import javax.net.ssl.SSLSocketFactory /** * The HTTP clients the CalDAV layer talks through. @@ -45,7 +49,8 @@ object CalDavHttp { } /** Discovery before we have credentials, and the Nextcloud login flow. */ - fun anonymous(userAgent: String): OkHttpClient = base(userAgent).build() + fun anonymous(userAgent: String, trust: ServerTrust = ServerTrust.DEFAULT): OkHttpClient = + base(userAgent, trust).build() /** * Authenticated against [origin]'s registrable domain. @@ -75,6 +80,7 @@ object CalDavHttp { username: String, password: String, origin: HttpUrl, + trust: ServerTrust = ServerTrust.DEFAULT, ): OkHttpClient { val handler = BasicDigestAuthHandler( // ⚠️ The **registrable** domain, not the host, and it must be @@ -97,16 +103,34 @@ object CalDavHttp { domain = origin.topPrivateDomain() ?: origin.host, username = username, password = password, - // Never over cleartext, challenged or not. - insecureBasic = false, + // Never over cleartext, challenged or not — unless the user opted + // this account into plain http, where nothing else could work. + insecureBasic = trust.allowCleartext, ) - return base(userAgent) + return base(userAgent, trust) .authenticator(handler) .addNetworkInterceptor(handler) .build() } - private fun base(userAgent: String) = shared.newBuilder() + /** + * Every client verifies TLS through [PinningTrustManager], and speaks plain + * http only when [trust] says the user opted in: without `CLEARTEXT` in the + * connection specs OkHttp itself refuses an `http://` URL. + */ + private fun base(userAgent: String, trust: ServerTrust) = shared.newBuilder() + .apply { + val tls = tlsFor(trust) + sslSocketFactory(tls.socketFactory, tls.trustManager) + hostnameVerifier(tls.hostnameVerifier) + connectionSpecs( + if (trust.allowCleartext) { + listOf(ConnectionSpec.MODERN_TLS, ConnectionSpec.CLEARTEXT) + } else { + listOf(ConnectionSpec.MODERN_TLS) + }, + ) + } .addInterceptor(calendarHeaders) .addInterceptor { chain -> chain.proceed( @@ -114,6 +138,23 @@ object CalDavHttp { ) } + private class Tls(trust: ServerTrust) { + val trustManager = PinningTrustManager(trust) + val socketFactory: SSLSocketFactory = SSLContext.getInstance("TLS") + .apply { init(null, arrayOf(trustManager), null) } + .socketFactory + val hostnameVerifier = PinningHostnameVerifier(trust) + } + + /** + * One socket factory per distinct trust, because OkHttp only pools a + * connection between clients whose factory and verifier are the same + * instances — a fresh one per call would reopen TLS for every client. + */ + private val tlsCache = ConcurrentHashMap() + + private fun tlsFor(trust: ServerTrust): Tls = tlsCache.getOrPut(trust) { Tls(trust) } + /** * The two headers that decide whether ETags are usable and whether our bytes * survive the server. diff --git a/caldav/src/main/kotlin/de/jeanlucmakiola/caldav/PinningTrustManager.kt b/caldav/src/main/kotlin/de/jeanlucmakiola/caldav/PinningTrustManager.kt new file mode 100644 index 0000000..013d783 --- /dev/null +++ b/caldav/src/main/kotlin/de/jeanlucmakiola/caldav/PinningTrustManager.kt @@ -0,0 +1,102 @@ +package de.jeanlucmakiola.caldav + +import okhttp3.internal.tls.OkHostnameVerifier +import java.net.Socket +import java.security.KeyStore +import java.security.cert.CertificateException +import java.security.cert.X509Certificate +import javax.net.ssl.HostnameVerifier +import javax.net.ssl.SSLEngine +import javax.net.ssl.SSLSession +import javax.net.ssl.TrustManagerFactory +import javax.net.ssl.X509ExtendedTrustManager +import javax.net.ssl.X509TrustManager + +/** + * The platform's trust decision, with the user's pinned certificates as the + * only exception to it. + * + * On Android the platform manager is the network security config's, so system + * and user-installed CAs keep working exactly as before. A certificate it + * rejects is accepted only if its leaf is pinned in [trust]; otherwise the + * failure carries the certificate so the user can be asked about it. + */ +internal class PinningTrustManager( + private val trust: ServerTrust, + private val platform: X509TrustManager = platformTrustManager, +) : X509ExtendedTrustManager() { + + override fun checkServerTrusted(chain: Array, authType: String) = + pinnedOr(chain) { platform.checkServerTrusted(chain, authType) } + + override fun checkServerTrusted(chain: Array, authType: String, socket: Socket?) = + pinnedOr(chain) { + if (platform is X509ExtendedTrustManager) { + platform.checkServerTrusted(chain, authType, socket) + } else { + platform.checkServerTrusted(chain, authType) + } + } + + override fun checkServerTrusted(chain: Array, authType: String, engine: SSLEngine?) = + pinnedOr(chain) { + if (platform is X509ExtendedTrustManager) { + platform.checkServerTrusted(chain, authType, engine) + } else { + platform.checkServerTrusted(chain, authType) + } + } + + override fun checkClientTrusted(chain: Array, authType: String) = + platform.checkClientTrusted(chain, authType) + + override fun checkClientTrusted(chain: Array, authType: String, socket: Socket?) = + if (platform is X509ExtendedTrustManager) { + platform.checkClientTrusted(chain, authType, socket) + } else { + platform.checkClientTrusted(chain, authType) + } + + override fun checkClientTrusted(chain: Array, authType: String, engine: SSLEngine?) = + if (platform is X509ExtendedTrustManager) { + platform.checkClientTrusted(chain, authType, engine) + } else { + platform.checkClientTrusted(chain, authType) + } + + override fun getAcceptedIssuers(): Array = platform.acceptedIssuers + + private inline fun pinnedOr(chain: Array, check: () -> Unit) { + try { + check() + } catch (error: CertificateException) { + val leaf = chain.firstOrNull() ?: throw error + if (trust.trusts(leaf)) return + throw UntrustedCertificateException(leaf, error) + } + } + + companion object { + /** The platform default, honouring Android's network security config. */ + val platformTrustManager: X509TrustManager by lazy { + val factory = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()) + factory.init(null as KeyStore?) + factory.trustManagers.filterIsInstance().first() + } + } +} + +/** + * OkHttp's hostname check, waived for a pinned leaf: a self-signed certificate + * is routinely issued for a name other than the one the phone dials. + */ +internal class PinningHostnameVerifier(private val trust: ServerTrust) : HostnameVerifier { + + override fun verify(host: String, session: SSLSession): Boolean { + if (OkHostnameVerifier.verify(host, session)) return true + val leaf = runCatching { session.peerCertificates.firstOrNull() as? X509Certificate } + .getOrNull() ?: return false + if (trust.trusts(leaf)) return true + throw UntrustedHostException(leaf, host) + } +} diff --git a/caldav/src/main/kotlin/de/jeanlucmakiola/caldav/ServerTrust.kt b/caldav/src/main/kotlin/de/jeanlucmakiola/caldav/ServerTrust.kt new file mode 100644 index 0000000..0164d9b --- /dev/null +++ b/caldav/src/main/kotlin/de/jeanlucmakiola/caldav/ServerTrust.kt @@ -0,0 +1,85 @@ +package de.jeanlucmakiola.caldav + +import java.security.MessageDigest +import java.security.cert.CertificateException +import java.security.cert.X509Certificate +import javax.net.ssl.SSLPeerUnverifiedException + +/** + * What the user has agreed to accept from one account's server beyond the + * platform's defaults. + * + * @property pinnedCertificates SHA-256 fingerprints of leaf certificates the + * user trusted on first use, lowercase hex without separators. + * @property allowCleartext whether plain `http://` may be spoken at all. + */ +data class ServerTrust( + val pinnedCertificates: Set = emptySet(), + val allowCleartext: Boolean = false, +) { + fun withCertificate(fingerprint: String): ServerTrust = + copy(pinnedCertificates = pinnedCertificates + fingerprint.lowercase()) + + fun trusts(certificate: X509Certificate): Boolean = + pinnedCertificates.isNotEmpty() && fingerprintOf(certificate) in pinnedCertificates + + companion object { + val DEFAULT = ServerTrust() + } +} + +/** A certificate the platform does not trust, as the user is shown it. */ +data class ServerCertificate( + /** SHA-256 of the DER encoding, lowercase hex without separators. */ + val fingerprint: String, + val subject: String, + val issuer: String, + val notAfterMillis: Long, +) { + /** The fingerprint as `AB:CD:…`, the way server tooling prints it. */ + val displayFingerprint: String get() = formatFingerprint(fingerprint) + + companion object { + fun of(certificate: X509Certificate) = ServerCertificate( + fingerprint = fingerprintOf(certificate), + subject = certificate.subjectX500Principal.name, + issuer = certificate.issuerX500Principal.name, + notAfterMillis = certificate.notAfter.time, + ) + + fun formatFingerprint(fingerprint: String): String = + fingerprint.uppercase().chunked(2).joinToString(":") + } +} + +/** SHA-256 of [certificate]'s DER encoding, lowercase hex. */ +fun fingerprintOf(certificate: X509Certificate): String = + MessageDigest.getInstance("SHA-256") + .digest(certificate.encoded) + .joinToString("") { "%02x".format(it) } + +/** The platform rejected [certificate] and the user has not pinned it. */ +class UntrustedCertificateException( + val certificate: X509Certificate, + cause: Throwable?, +) : CertificateException("certificate ${fingerprintOf(certificate)} is not trusted", cause) + +/** [certificate] does not name [host], and the user has not pinned it. */ +class UntrustedHostException( + val certificate: X509Certificate, + host: String, +) : SSLPeerUnverifiedException("certificate ${fingerprintOf(certificate)} does not name $host") + +/** The certificate the user could choose to trust, if [error] was caused by one. */ +fun untrustedCertificateIn(error: Throwable): X509Certificate? { + var current: Throwable? = error + val seen = mutableSetOf() + while (current != null && seen.add(current)) { + when (current) { + is UntrustedCertificateException -> return current.certificate + is UntrustedHostException -> return current.certificate + } + current = current.cause + } + return null +} diff --git a/caldav/src/test/kotlin/de/jeanlucmakiola/caldav/CalendarCollectionTest.kt b/caldav/src/test/kotlin/de/jeanlucmakiola/caldav/CalendarCollectionTest.kt index 5f53406..f740f7c 100644 --- a/caldav/src/test/kotlin/de/jeanlucmakiola/caldav/CalendarCollectionTest.kt +++ b/caldav/src/test/kotlin/de/jeanlucmakiola/caldav/CalendarCollectionTest.kt @@ -504,7 +504,7 @@ END:VCALENDAR // --------------------------------------------------------------- headers @Test fun `writes carry Prefer strict and identity encoding`() { - val client = CalDavHttp.anonymous("Agendula test") + val client = CalDavHttp.anonymous("Agendula test", ServerTrust(allowCleartext = true)) val withHeaders = CalendarCollection(client, server.url("/dav/tasks/")) server.enqueue(MockResponse().setResponseCode(201).setHeader("ETag", "\"e\"")) @@ -519,7 +519,7 @@ END:VCALENDAR } @Test fun `a caller's own Prefer survives beside ours`() { - val client = CalDavHttp.anonymous("Agendula test") + val client = CalDavHttp.anonymous("Agendula test", ServerTrust(allowCleartext = true)) server.enqueue(MockResponse().setResponseCode(200)) client.newCall( @@ -536,7 +536,7 @@ END:VCALENDAR } @Test fun `a body gzipped despite identity is still readable`() { - val client = CalDavHttp.anonymous("Agendula test") + val client = CalDavHttp.anonymous("Agendula test", ServerTrust(allowCleartext = true)) val body = okio.Buffer() okio.GzipSink(body).buffer().use { it.writeUtf8("BEGIN:VCALENDAR") } server.enqueue( diff --git a/caldav/src/test/kotlin/de/jeanlucmakiola/caldav/CollectionAdminTest.kt b/caldav/src/test/kotlin/de/jeanlucmakiola/caldav/CollectionAdminTest.kt index 7bba58d..dce7577 100644 --- a/caldav/src/test/kotlin/de/jeanlucmakiola/caldav/CollectionAdminTest.kt +++ b/caldav/src/test/kotlin/de/jeanlucmakiola/caldav/CollectionAdminTest.kt @@ -21,7 +21,7 @@ class CollectionAdminTest { @Before fun setUp() { server = MockWebServer().apply { start() } - admin = DavCollectionAdmin(CalDavHttp.anonymous("test")) + admin = DavCollectionAdmin(CalDavHttp.anonymous("test", ServerTrust(allowCleartext = true))) } @After fun tearDown() = server.shutdown() diff --git a/caldav/src/test/kotlin/de/jeanlucmakiola/caldav/ServerTrustTest.kt b/caldav/src/test/kotlin/de/jeanlucmakiola/caldav/ServerTrustTest.kt new file mode 100644 index 0000000..44b1202 --- /dev/null +++ b/caldav/src/test/kotlin/de/jeanlucmakiola/caldav/ServerTrustTest.kt @@ -0,0 +1,163 @@ +package de.jeanlucmakiola.caldav + +import com.google.common.truth.Truth.assertThat +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import okhttp3.tls.HandshakeCertificates +import okhttp3.tls.HeldCertificate +import org.junit.After +import org.junit.Before +import org.junit.Test +import java.io.IOException +import java.net.ConnectException +import java.net.UnknownServiceException +import java.security.cert.CertificateException +import javax.net.ssl.SSLContext +import javax.net.ssl.SSLHandshakeException + +/** Trust on first use for self-signed servers, and the per-account cleartext opt-in. */ +class ServerTrustTest { + + private val server = MockWebServer() + + @Before fun start() = server.start() + + @After fun stop() = server.shutdown() + + private fun selfSigned(host: String = server.hostName): HeldCertificate = + HeldCertificate.Builder() + .commonName(host) + .addSubjectAlternativeName(host) + .build() + + private fun serveTls(certificate: HeldCertificate) { + val handshake = HandshakeCertificates.Builder().heldCertificate(certificate).build() + server.useHttps(handshake.sslSocketFactory(), false) + } + + private fun options() = MockResponse().setResponseCode(200).setHeader("DAV", "1, 2, calendar-access") + + @Test + fun `a fingerprint is shown as colon-separated uppercase pairs`() { + assertThat(ServerCertificate.formatFingerprint("ab01cd")).isEqualTo("AB:01:CD") + } + + @Test + fun `the certificate is found anywhere in the cause chain`() { + val certificate = selfSigned().certificate + val wrapped = IOException(SSLHandshakeException("handshake").apply { + initCause(UntrustedCertificateException(certificate, CertificateException("path"))) + }) + + assertThat(untrustedCertificateIn(wrapped)).isEqualTo(certificate) + assertThat(untrustedCertificateIn(SSLHandshakeException("plain"))).isNull() + } + + @Test + fun `a self-signed server is reported with its fingerprint, not as unreachable`() { + val certificate = selfSigned() + serveTls(certificate) + server.enqueue(options()) + + val outcome = CalDavDiscovery(CalDavHttp.anonymous("test")).probe(server.url("/dav/")) + + assertThat(outcome).isInstanceOf(CalDavDiscovery.Outcome.UntrustedCertificate::class.java) + assertThat((outcome as CalDavDiscovery.Outcome.UntrustedCertificate).certificate.fingerprint) + .isEqualTo(fingerprintOf(certificate.certificate)) + } + + @Test + fun `a pinned certificate is accepted`() { + val certificate = selfSigned() + serveTls(certificate) + server.enqueue(options()) + server.enqueue(MockResponse().setResponseCode(401)) + + val trust = ServerTrust.DEFAULT.withCertificate(fingerprintOf(certificate.certificate)) + val outcome = CalDavDiscovery(CalDavHttp.anonymous("test", trust)).probe(server.url("/dav/")) + + assertThat(outcome).isInstanceOf(CalDavDiscovery.Outcome.NeedsAuthentication::class.java) + } + + @Test + fun `a pinned certificate is accepted for a name it does not carry`() { + val certificate = selfSigned(host = "nas.home.arpa") + serveTls(certificate) + server.enqueue(MockResponse().setResponseCode(200)) + + val trust = ServerTrust.DEFAULT.withCertificate(fingerprintOf(certificate.certificate)) + CalDavHttp.anonymous("test", trust) + .newCall(Request.Builder().url(server.url("/")).build()) + .execute() + .use { assertThat(it.code).isEqualTo(200) } + } + + @Test + fun `a trusted certificate for another name can be pinned too`() { + // Platform-trusted chain, wrong host: the hostname check is what fails, + // and it has to carry the certificate just the same. + val root = HeldCertificate.Builder().certificateAuthority(0).build() + val leaf = HeldCertificate.Builder().commonName("elsewhere.example") + .addSubjectAlternativeName("elsewhere.example").signedBy(root).build() + server.useHttps( + HandshakeCertificates.Builder().heldCertificate(leaf, root.certificate).build().sslSocketFactory(), + false, + ) + val platform = HandshakeCertificates.Builder().addTrustedCertificate(root.certificate).build() + + fun client(trust: ServerTrust): OkHttpClient { + val trustManager = PinningTrustManager(trust, platform.trustManager) + val context = SSLContext.getInstance("TLS").apply { init(null, arrayOf(trustManager), null) } + return OkHttpClient.Builder() + .sslSocketFactory(context.socketFactory, trustManager) + .hostnameVerifier(PinningHostnameVerifier(trust)) + .build() + } + + server.enqueue(MockResponse().setResponseCode(200)) + val refused = runCatching { + client(ServerTrust.DEFAULT).newCall(Request.Builder().url(server.url("/")).build()).execute() + }.exceptionOrNull() + assertThat(refused).isNotNull() + assertThat(untrustedCertificateIn(refused!!)).isEqualTo(leaf.certificate) + + val trust = ServerTrust.DEFAULT.withCertificate(fingerprintOf(leaf.certificate)) + client(trust).newCall(Request.Builder().url(server.url("/")).build()).execute() + .use { assertThat(it.code).isEqualTo(200) } + } + + @Test + fun `a TLS failure that is not about a certificate is its own cause`() { + val discovery = CalDavDiscovery(OkHttpClient()) + + assertThat(discovery.causeOf(SSLHandshakeException("no shared cipher"))) + .isEqualTo(CalDavDiscovery.Outcome.Cause.TLS) + assertThat(discovery.causeOf(ConnectException("refused"))) + .isEqualTo(CalDavDiscovery.Outcome.Cause.UNREACHABLE) + } + + @Test + fun `plain http is refused unless the account opted in`() { + server.enqueue(MockResponse().setResponseCode(200)) + val request = Request.Builder().url(server.url("/")).build() + + val refused = runCatching { CalDavHttp.anonymous("test").newCall(request).execute() } + assertThat(refused.exceptionOrNull()).isInstanceOf(UnknownServiceException::class.java) + + CalDavHttp.anonymous("test", ServerTrust(allowCleartext = true)).newCall(request).execute() + .use { assertThat(it.code).isEqualTo(200) } + } + + @Test + fun `an opted-in account may discover over http`() { + server.enqueue(options()) + server.enqueue(MockResponse().setResponseCode(401)) + val trust = ServerTrust(allowCleartext = true) + val discovery = CalDavDiscovery(CalDavHttp.anonymous("test", trust), allowCleartext = true) + + assertThat(discovery.probe(server.url("/dav/"))) + .isInstanceOf(CalDavDiscovery.Outcome.NeedsAuthentication::class.java) + } +}