Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4a7c357c5f | ||
|
|
558e63bc1c |
@@ -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<TaskCollection>,
|
||||
/** 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<TaskCollection>,
|
||||
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<TaskCollection>,
|
||||
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<Long>) = database.runInTransaction {
|
||||
inserted.forEach { database.taskLists().delete(it) }
|
||||
database.accounts().delete(accountId)
|
||||
private suspend fun rollback(accountId: Long, inserted: List<Long>) {
|
||||
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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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<Preferences>,
|
||||
) {
|
||||
|
||||
suspend fun get(accountId: Long): ServerTrust = read(dataStore.data.first(), accountId)
|
||||
|
||||
fun observe(accountId: Long): Flow<ServerTrust> =
|
||||
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")
|
||||
}
|
||||
@@ -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()])
|
||||
|
||||
@@ -49,6 +49,7 @@ data class SyncFailure(val kind: Kind, val httpCode: Int? = null) {
|
||||
private val TLS = listOf(
|
||||
"SSLHandshakeException",
|
||||
"SSLPeerUnverifiedException",
|
||||
"UntrustedHostException",
|
||||
"CertPathValidatorException",
|
||||
"CertificateException",
|
||||
"SSLException",
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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> = serverTrust.observe(accountId)
|
||||
|
||||
/** The user has read what the last sync changed. */
|
||||
fun dismissNotices(account: AccountEntity) {
|
||||
viewModelScope.launch { notices.dismiss(account.id) }
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
+178
-49
@@ -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<ArrayList<String>>(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<String>
|
||||
get() = handle.get<ArrayList<String>>(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(
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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.
|
||||
-->
|
||||
<network-security-config>
|
||||
<base-config cleartextTrafficPermitted="false">
|
||||
<base-config cleartextTrafficPermitted="true">
|
||||
<trust-anchors>
|
||||
<certificates src="system" />
|
||||
<certificates src="user" />
|
||||
|
||||
@@ -688,6 +688,9 @@
|
||||
<string name="accounts_last_sync">Last sync</string>
|
||||
<string name="accounts_name">Name</string>
|
||||
<string name="accounts_name_hint">How this account is shown in Agendula. Leave it empty to use “%1$s”.</string>
|
||||
<string name="accounts_connection">Connection</string>
|
||||
<string name="accounts_connection_cleartext">Unencrypted (http)</string>
|
||||
<string name="accounts_trusted_certificate">Trusted certificate</string>
|
||||
<string name="accounts_generic_provider">CalDAV account</string>
|
||||
<string name="accounts_sync_failed">Last sync didn\u2019t finish</string>
|
||||
<string name="accounts_failed_sign_in">Sync failed \u2014 the server didn\u2019t accept the password</string>
|
||||
@@ -737,6 +740,7 @@
|
||||
<string name="add_account_error_insecure">That address is unencrypted. Agendula only sends your password over https.</string>
|
||||
<string name="add_account_error_no_calendars">Signed in, but this account has no task lists.</string>
|
||||
<string name="add_account_error_server">The server ran into a problem. Try again in a moment.</string>
|
||||
<string name="add_account_error_tls">Couldn\u2019t set up a secure connection to that server.</string>
|
||||
<string name="add_account_working_discovering">Looking for a CalDAV server</string>
|
||||
<string name="add_account_working_signing_in">Signing in</string>
|
||||
<string name="add_account_working_reading_lists">Reading your task lists</string>
|
||||
@@ -815,6 +819,20 @@
|
||||
<string name="add_account_browser_host_mismatch">The server sent us to %1$s, but you typed %2$s. That usually means its overwrite.cli.url setting is wrong.</string>
|
||||
<string name="add_account_origin_mismatch">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.</string>
|
||||
|
||||
<string name="add_account_cleartext_title">Connect without encryption?</string>
|
||||
<string name="add_account_cleartext_body">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.</string>
|
||||
<string name="add_account_cleartext_note">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.</string>
|
||||
<string name="add_account_cleartext_connect">Connect anyway</string>
|
||||
<string name="add_account_change_address">Change the address</string>
|
||||
|
||||
<string name="add_account_certificate_title">Trust this certificate?</string>
|
||||
<string name="add_account_certificate_body">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.</string>
|
||||
<string name="add_account_certificate_fingerprint">SHA-256 fingerprint</string>
|
||||
<string name="add_account_certificate_subject">Issued to</string>
|
||||
<string name="add_account_certificate_issuer">Issued by</string>
|
||||
<string name="add_account_certificate_expires">Valid until</string>
|
||||
<string name="add_account_certificate_trust">Trust this certificate</string>
|
||||
|
||||
<string name="add_account_lists_title">Which lists should sync?</string>
|
||||
<string name="add_account_lists_read_only">Read-only</string>
|
||||
<string name="add_account_lists_shared">Shared with you</string>
|
||||
|
||||
@@ -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<Preferences> 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()
|
||||
}
|
||||
}
|
||||
+181
-5
@@ -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<Call>()
|
||||
val discoveryOutcomes = ArrayDeque<CalDavDiscovery.Outcome>()
|
||||
@@ -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<CalDavGateway.Credentials>()
|
||||
@@ -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<String>()
|
||||
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<Long?>()
|
||||
var thrown: Throwable? = null
|
||||
var outcome: AccountRepository.Outcome? = null
|
||||
val savedTrust = mutableListOf<ServerTrust>()
|
||||
val storedTrust = mutableMapOf<Long, ServerTrust>()
|
||||
|
||||
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<TaskCollection>,
|
||||
reauthenticating: Long?,
|
||||
trust: ServerTrust,
|
||||
): AccountRepository.Outcome {
|
||||
reauthenticated += reauthenticating
|
||||
savedTrust += trust
|
||||
thrown?.let { throw it }
|
||||
outcome?.let { return it }
|
||||
created += displayName
|
||||
|
||||
@@ -32,5 +32,6 @@ dependencies {
|
||||
testImplementation(libs.xpp3)
|
||||
testImplementation(libs.junit4)
|
||||
testImplementation(libs.okhttp.mockwebserver)
|
||||
testImplementation(libs.okhttp.tls)
|
||||
testImplementation(libs.truth)
|
||||
}
|
||||
|
||||
@@ -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 <D:unauthenticated/> 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
|
||||
|
||||
|
||||
@@ -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<ServerTrust, Tls>()
|
||||
|
||||
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.
|
||||
|
||||
@@ -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<out X509Certificate>, authType: String) =
|
||||
pinnedOr(chain) { platform.checkServerTrusted(chain, authType) }
|
||||
|
||||
override fun checkServerTrusted(chain: Array<out X509Certificate>, 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<out X509Certificate>, 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<out X509Certificate>, authType: String) =
|
||||
platform.checkClientTrusted(chain, authType)
|
||||
|
||||
override fun checkClientTrusted(chain: Array<out X509Certificate>, authType: String, socket: Socket?) =
|
||||
if (platform is X509ExtendedTrustManager) {
|
||||
platform.checkClientTrusted(chain, authType, socket)
|
||||
} else {
|
||||
platform.checkClientTrusted(chain, authType)
|
||||
}
|
||||
|
||||
override fun checkClientTrusted(chain: Array<out X509Certificate>, authType: String, engine: SSLEngine?) =
|
||||
if (platform is X509ExtendedTrustManager) {
|
||||
platform.checkClientTrusted(chain, authType, engine)
|
||||
} else {
|
||||
platform.checkClientTrusted(chain, authType)
|
||||
}
|
||||
|
||||
override fun getAcceptedIssuers(): Array<X509Certificate> = platform.acceptedIssuers
|
||||
|
||||
private inline fun pinnedOr(chain: Array<out X509Certificate>, 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<X509TrustManager>().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)
|
||||
}
|
||||
}
|
||||
@@ -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<String> = 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<Throwable>()
|
||||
while (current != null && seen.add(current)) {
|
||||
when (current) {
|
||||
is UntrustedCertificateException -> return current.certificate
|
||||
is UntrustedHostException -> return current.certificate
|
||||
}
|
||||
current = current.cause
|
||||
}
|
||||
return null
|
||||
}
|
||||
@@ -504,7 +504,7 @@ END:VCALENDAR</C:calendar-data>
|
||||
// --------------------------------------------------------------- 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</C:calendar-data>
|
||||
}
|
||||
|
||||
@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</C:calendar-data>
|
||||
}
|
||||
|
||||
@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(
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user