Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -80,8 +80,7 @@ class NetworkLoginDataSource(val okHttpClient: OkHttpClient) {
.header("User-Agent", ApiUtils.loginUserAgent)
.build()

val newOkHttpClient = OkHttpClient()
newOkHttpClient.newCall(request).execute().use { response ->
client.newCall(request).execute().use { response ->
if (response.code == HTTP_TOO_MANY_REQUESTS) {
throw TooManyLoginAttemptsException("Too many failed logins from this IP: $response")
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
import com.nextcloud.talk.utils.ApiUtils;
import com.nextcloud.talk.utils.RejectedCredentialsInterceptor;
import com.nextcloud.talk.utils.preferences.AppPreferences;
import com.nextcloud.talk.utils.ssl.ClientCertificateUsers;
import com.nextcloud.talk.utils.ssl.KeyManager;
import com.nextcloud.talk.utils.ssl.SSLSocketFactoryCompat;
import com.nextcloud.talk.utils.ssl.TrustManager;
Expand Down Expand Up @@ -130,7 +131,7 @@ KeyManager provideKeyManager(AppPreferences appPreferences, UserManager userMana
KeyManagerFactory kmf = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm());
kmf.init(keyStore, null);
X509KeyManager origKm = (X509KeyManager) kmf.getKeyManagers()[0];
return new KeyManager(origKm, userManager, appPreferences);
return new KeyManager(origKm, new ClientCertificateUsers(userManager), appPreferences);
} catch (KeyStoreException e) {
Log.e(TAG, "KeyStoreException " + e.getLocalizedMessage());
} catch (CertificateException e) {
Expand Down
3 changes: 3 additions & 0 deletions app/src/main/java/com/nextcloud/talk/data/user/UsersDao.kt
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,9 @@ interface UsersDao {
@Query("SELECT * FROM User where scheduledForDeletion != 1")
suspend fun getUsers(): List<UserEntity>

@Query("SELECT * FROM User where scheduledForDeletion != 1")
fun getUsersFlow(): Flow<List<UserEntity>>

@Query("SELECT * FROM User where id = :id")
suspend fun getUserWithId(id: Long): UserEntity?

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ interface UsersRepository {
suspend fun getActiveUser(): User?
fun getActiveUserFlow(): Flow<User?>
suspend fun getUsers(): List<User>
fun getUsersFlow(): Flow<List<User>>
suspend fun getUserWithId(id: Long): User?
fun getUserWithIdFlow(id: Long): Flow<User?>
suspend fun repairMultipleActiveUsers(): Int
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ class UsersRepositoryImpl(private val usersDao: UsersDao) : UsersRepository {

override suspend fun getUsers(): List<User> = UserMapper.toModel(usersDao.getUsers())

override fun getUsersFlow(): Flow<List<User>> = usersDao.getUsersFlow().map { UserMapper.toModel(it) }

override suspend fun getUserWithId(id: Long): User? = UserMapper.toModel(usersDao.getUserWithId(id))

override fun getUserWithIdFlow(id: Long): Flow<User?> =
Expand Down
5 changes: 5 additions & 0 deletions app/src/main/java/com/nextcloud/talk/users/UserManager.kt
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,11 @@ class UserManager internal constructor(private val userRepository: UsersReposito

suspend fun getUsers(): List<User> = userRepository.getUsers()

/**
* Emits the users not scheduled for deletion whenever the user table changes.
*/
fun usersFlow(): Flow<List<User>> = userRepository.getUsersFlow()

suspend fun getUsersScheduledForDeletion(): List<User> = userRepository.getUsersScheduledForDeletion()

suspend fun getUsersNotScheduledForDeletion(): List<User> = userRepository.getUsersNotScheduledForDeletion()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,8 @@ class DummyUserDaoImpl : UsersDao {

override suspend fun getUsers(): List<UserEntity> = dummyUsers.filter { !it.scheduledForDeletion }

override fun getUsersFlow(): Flow<List<UserEntity>> = flowOf(dummyUsers.filter { !it.scheduledForDeletion })

override suspend fun getUserWithId(id: Long): UserEntity? = dummyUsers.find { it.id == id }

override fun getUserWithIdFlow(id: Long): Flow<UserEntity?> = flowOf(dummyUsers.find { it.id == id })
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,26 +15,35 @@ import java.net.URISyntaxException
*
* The TLS handshake only knows the server (host and port), not the account of the request. So the certificate is
* chosen by the server: the one of the accounts on it. Only if accounts on the same server use different certificates,
* the default account decides, as the connection may then be reused by all of them.
* the active account decides, as the connection may then be reused by all of them.
*/
object ClientCertificateSelector {

/**
* Returns the alias of the client certificate for a connection to [host] and [port], or null if no account on
* that server uses one. Without a [host], the certificate of [defaultUser] is used. A negative [port] is unknown,
* then the host alone identifies the server.
* that server uses one. Without a [host], the certificate of the active account is used. A negative [port] is
* unknown, then the host alone identifies the server.
*/
@JvmStatic
fun selectAlias(host: String?, port: Int, users: List<User>, defaultUser: User?): String? {
fun selectAlias(host: String?, port: Int, users: List<User>): String? {
val usersOnServer = users.filter { host != null && isOnServer(it, host, port) }
val aliases = usersOnServer.mapNotNull { aliasOf(it) }.toSet()
return when {
host == null -> aliasOf(defaultUser)
host == null -> aliasOf(activeUserOf(users))
aliases.size <= 1 -> aliases.firstOrNull()
else -> defaultUser?.takeIf { default -> usersOnServer.any { it.id == default.id } }?.let { aliasOf(it) }
else -> aliasOf(activeUserOf(usersOnServer))
}
}

/**
* Returns the aliases of the client certificates of all [users].
*/
@JvmStatic
fun aliasesOf(users: List<User>): Set<String> = users.mapNotNull { aliasOf(it) }.toSet()

// Matches UsersDao.getActiveUser(), which prefers the highest id if several users are marked as active.
private fun activeUserOf(users: List<User>): User? = users.filter { it.current }.maxByOrNull { it.id ?: -1L }

private fun isOnServer(user: User, host: String, port: Int): Boolean {
val uri = uriOf(user) ?: return false
return uri.host.equals(host, ignoreCase = true) && (port < 0 || effectivePortOf(uri) == port)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
/*
* Nextcloud Talk - Android Client
*
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.nextcloud.talk.utils.ssl

import com.nextcloud.talk.data.user.model.User
import com.nextcloud.talk.users.UserManager
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking

/**
* The accounts [KeyManager] chooses client certificates from. They are kept up to date from the database, so TLS
* handshakes don't have to query it.
*/
class ClientCertificateUsers @JvmOverloads constructor(
private val userManager: UserManager,
scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
) {
@Volatile
private var users: List<User>? = null

init {
scope.launch {
userManager.usersFlow().collect { users = it }
}
}

/**
* The users not scheduled for deletion. Until the database emitted them for the first time, they are queried
* directly.
*/
fun getUsers(): List<User> = users ?: runBlocking { userManager.getUsers() }
}
95 changes: 42 additions & 53 deletions app/src/main/java/com/nextcloud/talk/utils/ssl/KeyManager.java
Original file line number Diff line number Diff line change
Expand Up @@ -13,39 +13,38 @@
import android.util.Log;

import com.nextcloud.talk.application.NextcloudTalkApplication;
import com.nextcloud.talk.data.user.model.User;
import com.nextcloud.talk.users.UserManager;
import com.nextcloud.talk.utils.preferences.AppPreferences;

import java.net.InetSocketAddress;
import java.net.Socket;
import java.net.SocketAddress;
import java.security.Principal;
import java.security.PrivateKey;
import java.security.cert.X509Certificate;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashSet;
import java.util.List;
import java.util.Set;

import javax.net.ssl.SSLEngine;
import javax.net.ssl.SSLSession;
import javax.net.ssl.SSLSocket;
import javax.net.ssl.X509ExtendedKeyManager;
import javax.net.ssl.X509KeyManager;
import kotlin.coroutines.EmptyCoroutineContext;
import kotlinx.coroutines.BuildersKt;

import androidx.annotation.Nullable;

public class KeyManager implements X509KeyManager {
public class KeyManager extends X509ExtendedKeyManager {
private static final String TAG = "KeyManager";
private final X509KeyManager keyManager;

private UserManager userManager;
private final ClientCertificateUsers clientCertificateUsers;
private AppPreferences appPreferences;
private Context context;

public KeyManager(X509KeyManager keyManager, UserManager userManager, AppPreferences appPreferences) {
public KeyManager(X509KeyManager keyManager, ClientCertificateUsers clientCertificateUsers,
AppPreferences appPreferences) {
this.keyManager = keyManager;
this.userManager = userManager;
this.clientCertificateUsers = clientCertificateUsers;
this.appPreferences = appPreferences;

context = NextcloudTalkApplication.Companion.getSharedApplication().getApplicationContext();
Expand All @@ -58,31 +57,25 @@ public KeyManager(X509KeyManager keyManager, UserManager userManager, AppPrefere
*/
@Override
public String chooseClientAlias(String[] strings, Principal[] principals, Socket socket) {
String alias;
List<User> users = new ArrayList<>();
User defaultUser = null;
try {
users = BuildersKt.runBlocking(
EmptyCoroutineContext.INSTANCE,
(scope, continuation) -> userManager.getUsers(continuation));
defaultUser = BuildersKt.runBlocking(
EmptyCoroutineContext.INSTANCE,
(scope, continuation) -> userManager.getDefaultUser(continuation));
} catch (InterruptedException e) {
Log.e(TAG, "Interrupted while getting the users: " + e.getLocalizedMessage());
Thread.currentThread().interrupt();
}
if (!TextUtils.isEmpty(alias = ClientCertificateSelector.selectAlias(
getPeerHost(socket),
getPeerPort(socket),
users,
defaultUser)) ||
!TextUtils.isEmpty(alias = appPreferences.getTemporaryClientCertAlias())
&& new ArrayList<>(Arrays.asList(getClientAliases())).contains(alias)) {
return chooseAlias(getPeerHost(socket), getPeerPort(socket));
}

@Override
public String chooseEngineClientAlias(String[] keyType, Principal[] issuers, SSLEngine engine) {
return engine == null ? chooseAlias(null, -1) : chooseAlias(engine.getPeerHost(), engine.getPeerPort());
}

/**
* Falls back to the certificate chosen during login, as the account it is for doesn't exist yet.
*/
@Nullable
private String chooseAlias(@Nullable String host, int port) {
String alias = ClientCertificateSelector.selectAlias(host, port, clientCertificateUsers.getUsers());
if (!TextUtils.isEmpty(alias)) {
return alias;
}

return null;
alias = appPreferences.getTemporaryClientCertAlias();
return TextUtils.isEmpty(alias) ? null : alias;
}

private static int getPeerPort(@Nullable Socket socket) {
Expand All @@ -98,6 +91,10 @@ private static int getPeerPort(@Nullable Socket socket) {
return -1;
}

/**
* The host name the connection was opened for. Unlike InetAddress.getHostName(), the fallback never does a reverse
* DNS lookup.
*/
@Nullable
private static String getPeerHost(@Nullable Socket socket) {
if (socket instanceof SSLSocket) {
Expand All @@ -106,8 +103,11 @@ private static String getPeerHost(@Nullable Socket socket) {
return session.getPeerHost();
}
}
if (socket != null && socket.getInetAddress() != null) {
return socket.getInetAddress().getHostName();
if (socket != null) {
SocketAddress address = socket.getRemoteSocketAddress();
if (address instanceof InetSocketAddress) {
return ((InetSocketAddress) address).getHostString();
}
}
return null;
}
Expand Down Expand Up @@ -152,38 +152,27 @@ private PrivateKey getPrivateKeyForAlias(@Nullable String alias) {

@Override
public X509Certificate[] getCertificateChain(String s) {
if (new ArrayList<>(Arrays.asList(getClientAliases())).contains(s)) {
if (isClientAlias(s)) {
return getCertificatesForAlias(s);
}

return null;
}

private String[] getClientAliases() {
Set<String> aliases = new HashSet<>();
Set<String> aliases = new HashSet<>(ClientCertificateSelector.aliasesOf(clientCertificateUsers.getUsers()));
String alias;
if (!TextUtils.isEmpty(alias = appPreferences.getTemporaryClientCertAlias())) {
aliases.add(alias);
}

List<User> userEntities = new ArrayList<>();
try {
userEntities = BuildersKt.runBlocking(
EmptyCoroutineContext.INSTANCE,
(scope, continuation) -> userManager.getUsers(continuation));
} catch (InterruptedException e) {
Log.e(TAG, "Interrupted while getting users: " + e.getLocalizedMessage());
Thread.currentThread().interrupt();
}
for (int i = 0; i < userEntities.size(); i++) {
if (!TextUtils.isEmpty(alias = userEntities.get(i).getClientCertificate())) {
aliases.add(alias);
}
}

return aliases.toArray(new String[aliases.size()]);
}

private boolean isClientAlias(String alias) {
return Arrays.asList(getClientAliases()).contains(alias);
}

@Override
public String[] getClientAliases(String s, Principal[] principals) {
return getClientAliases();
Expand All @@ -196,7 +185,7 @@ public String[] getServerAliases(String s, Principal[] principals) {

@Override
public PrivateKey getPrivateKey(String s) {
if (new ArrayList<>(Arrays.asList(getClientAliases())).contains(s)) {
if (isClientAlias(s)) {
return getPrivateKeyForAlias(s);
}

Expand Down
Loading
Loading