diff --git a/library/src/main/AndroidManifest.xml b/library/src/main/AndroidManifest.xml index dab60d94c..fd7674c6d 100644 --- a/library/src/main/AndroidManifest.xml +++ b/library/src/main/AndroidManifest.xml @@ -39,10 +39,18 @@ android:name="android.permission.USE_CREDENTIALS" /> + + + tools:targetApi="m"> + + + diff --git a/library/src/main/java/com/nextcloud/common/NextcloudClient.kt b/library/src/main/java/com/nextcloud/common/NextcloudClient.kt index 2588fa52f..f7e91f21a 100644 --- a/library/src/main/java/com/nextcloud/common/NextcloudClient.kt +++ b/library/src/main/java/com/nextcloud/common/NextcloudClient.kt @@ -36,6 +36,7 @@ import com.owncloud.android.lib.common.OwnCloudClientFactory.DEFAULT_CONNECTION_ import com.owncloud.android.lib.common.OwnCloudClientFactory.DEFAULT_DATA_TIMEOUT_LONG import com.owncloud.android.lib.common.OwnCloudClientManagerFactory import com.owncloud.android.lib.common.accounts.AccountUtils +import com.owncloud.android.lib.common.network.AdvancedX509KeyManager import com.owncloud.android.lib.common.network.AdvancedX509TrustManager import com.owncloud.android.lib.common.network.NetworkUtils import com.owncloud.android.lib.common.network.RedirectionPath @@ -56,7 +57,8 @@ import javax.net.ssl.TrustManager class NextcloudClient private constructor( val delegate: NextcloudUriDelegate, var credentials: String, - val client: OkHttpClient + val client: OkHttpClient, + val context: Context ) : NextcloudUriProvider by delegate { var followRedirects = true @@ -64,8 +66,9 @@ class NextcloudClient private constructor( baseUri: Uri, userId: String, credentials: String, - client: OkHttpClient - ) : this(NextcloudUriDelegate(baseUri, userId), credentials, client) + client: OkHttpClient, + context: Context + ) : this(NextcloudUriDelegate(baseUri, userId), credentials, client, context) var userId: String get() = delegate.userId!! @@ -79,10 +82,11 @@ class NextcloudClient private constructor( private fun createDefaultClient(context: Context): OkHttpClient { val trustManager = AdvancedX509TrustManager(NetworkUtils.getKnownServersStore(context)) + val keyManager = AdvancedX509KeyManager(context) val sslContext = NetworkUtils.getSSLContext() - sslContext.init(null, arrayOf(trustManager), null) + sslContext.init(arrayOf(keyManager), arrayOf(trustManager), null) val sslSocketFactory = sslContext.socketFactory var proxy: Proxy? = null @@ -116,25 +120,43 @@ class NextcloudClient private constructor( userId: String, credentials: String, context: Context - ) : this(baseUri, userId, credentials, createDefaultClient(context)) + ) : this(baseUri, userId, credentials, createDefaultClient(context), context) @Suppress("TooGenericExceptionCaught") fun execute(remoteOperation: RemoteOperation): RemoteOperationResult { - return try { - remoteOperation.run(this) - } catch (ex: Exception) { - RemoteOperationResult(ex) + val result = + try { + remoteOperation.run(this) + } catch (ex: Exception) { + RemoteOperationResult(ex) + } + if (result.httpCode == HttpStatus.SC_BAD_REQUEST) { + val url = remoteOperation.client.hostConfiguration.hostURL + Log_OC.e(TAG, "Received http status 400 for $url -> removing client certificate") + AdvancedX509KeyManager(context).removeKeys(url) } + return result } @Throws(IOException::class) fun execute(method: OkHttpMethodBase): Int { - return method.execute(this) + val httpStatus = method.execute(this) + if (httpStatus == HttpStatus.SC_BAD_REQUEST) { + val uri = method.uri + Log_OC.e(TAG, "Received http status 400 for $uri -> removing client certificate") + AdvancedX509KeyManager(context).removeKeys(uri) + } + return httpStatus } internal fun execute(request: Request): ResponseOrError { return try { val response = client.newCall(request).execute() + if (response.code == HttpStatus.SC_BAD_REQUEST) { + val url = request.url + Log_OC.e(TAG, "Received http status 400 for $url -> removing client certificate") + AdvancedX509KeyManager(context).removeKeys(url) + } ResponseOrError(response) } catch (ex: IOException) { ResponseOrError(ex) diff --git a/library/src/main/java/com/nextcloud/common/PlainClient.kt b/library/src/main/java/com/nextcloud/common/PlainClient.kt index fc916eb21..f2cbcfc28 100644 --- a/library/src/main/java/com/nextcloud/common/PlainClient.kt +++ b/library/src/main/java/com/nextcloud/common/PlainClient.kt @@ -32,6 +32,7 @@ import android.content.Context import android.text.TextUtils import com.owncloud.android.lib.common.OwnCloudClientFactory.DEFAULT_DATA_TIMEOUT_LONG import com.owncloud.android.lib.common.OwnCloudClientManagerFactory +import com.owncloud.android.lib.common.network.AdvancedX509KeyManager import com.owncloud.android.lib.common.network.AdvancedX509TrustManager import com.owncloud.android.lib.common.network.NetworkUtils import com.owncloud.android.lib.common.utils.Log_OC @@ -55,10 +56,11 @@ class PlainClient(context: Context) { private fun createDefaultClient(context: Context): OkHttpClient { val trustManager = AdvancedX509TrustManager(NetworkUtils.getKnownServersStore(context)) + val keyManager = AdvancedX509KeyManager(context) val sslContext = NetworkUtils.getSSLContext() - sslContext.init(null, arrayOf(trustManager), null) + sslContext.init(arrayOf(keyManager), arrayOf(trustManager), null) val sslSocketFactory = sslContext.socketFactory var proxy: Proxy? = null diff --git a/library/src/main/java/com/owncloud/android/lib/common/OwnCloudClient.java b/library/src/main/java/com/owncloud/android/lib/common/OwnCloudClient.java index b238867e5..62c2c14dd 100644 --- a/library/src/main/java/com/owncloud/android/lib/common/OwnCloudClient.java +++ b/library/src/main/java/com/owncloud/android/lib/common/OwnCloudClient.java @@ -25,12 +25,14 @@ package com.owncloud.android.lib.common; +import android.content.Context; import android.net.Uri; import android.text.TextUtils; import com.nextcloud.common.DNSCache; import com.nextcloud.common.NextcloudUriDelegate; import com.owncloud.android.lib.common.accounts.AccountUtils; +import com.owncloud.android.lib.common.network.AdvancedX509KeyManager; import com.owncloud.android.lib.common.network.RedirectionPath; import com.owncloud.android.lib.common.utils.Log_OC; @@ -72,15 +74,18 @@ public class OwnCloudClient extends HttpClient { private OwnCloudCredentials credentials = null; private int mInstanceNumber; + private AdvancedX509KeyManager keyManager; + /** * Constructor */ - public OwnCloudClient(Uri baseUri, HttpConnectionManager connectionMgr) { + public OwnCloudClient(Uri baseUri, HttpConnectionManager connectionMgr, Context context) { super(connectionMgr); if (baseUri == null) { throw new IllegalArgumentException("Parameter 'baseUri' cannot be NULL"); } + this.keyManager = new AdvancedX509KeyManager(context); nextcloudUriDelegate = new NextcloudUriDelegate(baseUri); mInstanceNumber = sInstanceCounter++; @@ -156,7 +161,13 @@ public int executeMethod(HttpMethodBase method, int readTimeout, int connectionT if (connectionTimeout >= 0) { getHttpConnectionManager().getParams().setConnectionTimeout(connectionTimeout); } - return executeMethod(method); + int httpStatus = executeMethod(method); + if (httpStatus == HttpStatus.SC_BAD_REQUEST) { + URI uri = method.getURI(); + Log_OC.e(TAG, "Received http status 400 for " + uri + " -> removing client certificate"); + keyManager.removeKeys(uri); + } + return httpStatus; } finally { getParams().setSoTimeout(oldSoTimeout); getHttpConnectionManager().getParams().setConnectionTimeout(oldConnectionTimeout); @@ -191,6 +202,10 @@ public int executeMethod(HttpMethod method) throws IOException { if (status >= 500 && status < 600 && DNSCache.isIPV6First(hostname)) { return retryMethodWithIPv4(method, hostname); + } else if (status == HttpStatus.SC_BAD_REQUEST) { + URI uri = method.getURI(); + Log_OC.e(TAG, "Received http status 400 for " + uri + " -> removing client certificate"); + keyManager.removeKeys(uri); } if (followRedirects) { diff --git a/library/src/main/java/com/owncloud/android/lib/common/OwnCloudClientFactory.java b/library/src/main/java/com/owncloud/android/lib/common/OwnCloudClientFactory.java index 94f8a5bbe..1526f7feb 100644 --- a/library/src/main/java/com/owncloud/android/lib/common/OwnCloudClientFactory.java +++ b/library/src/main/java/com/owncloud/android/lib/common/OwnCloudClientFactory.java @@ -151,7 +151,7 @@ public static OwnCloudClient createOwnCloudClient(Uri uri, Context context, bool Log_OC.e(TAG, "The local server truststore could not be read. Default SSL management" + " in the system will be used for HTTPS connections", e); } - OwnCloudClient client = new OwnCloudClient(uri, NetworkUtils.getMultiThreadedConnManager()); + OwnCloudClient client = new OwnCloudClient(uri, NetworkUtils.getMultiThreadedConnManager(), context); client.setDefaultTimeouts(DEFAULT_DATA_TIMEOUT, DEFAULT_CONNECTION_TIMEOUT); client.setFollowRedirects(followRedirects); diff --git a/library/src/main/java/com/owncloud/android/lib/common/network/AdvancedX509KeyManager.java b/library/src/main/java/com/owncloud/android/lib/common/network/AdvancedX509KeyManager.java new file mode 100644 index 000000000..bbc9143e8 --- /dev/null +++ b/library/src/main/java/com/owncloud/android/lib/common/network/AdvancedX509KeyManager.java @@ -0,0 +1,846 @@ +/* The MIT license. + +Copyright (c) 2016-2022 Stephan Ritscher +Copyright (c) 2023 Elv1zz + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. + */ +package com.owncloud.android.lib.common.network; + +import static android.Manifest.permission.POST_NOTIFICATIONS; +import static android.content.pm.PackageManager.PERMISSION_GRANTED; +import static com.owncloud.android.lib.common.network.AdvancedX509KeyManager.AKMAlias.Type.KEYCHAIN; + +import android.app.Notification; +import android.app.NotificationChannel; +import android.app.NotificationManager; +import android.app.PendingIntent; +import android.content.Context; +import android.content.Intent; +import android.content.SharedPreferences; +import android.net.Uri; +import android.os.Build; +import android.security.KeyChain; +import android.security.KeyChainException; +import android.util.SparseArray; +import android.webkit.ClientCertRequest; + +import androidx.annotation.NonNull; +import androidx.annotation.RequiresApi; +import androidx.core.app.ActivityCompat; +import androidx.core.app.NotificationCompat; +import androidx.core.app.NotificationManagerCompat; + +import com.owncloud.android.lib.R; +import com.owncloud.android.lib.common.utils.Log_OC; + +import org.apache.commons.httpclient.URIException; + +import java.io.IOException; +import java.io.InputStream; +import java.net.InetAddress; +import java.net.MalformedURLException; +import java.net.Socket; +import java.net.URI; +import java.net.URL; +import java.net.UnknownHostException; +import java.nio.charset.Charset; +import java.security.Principal; +import java.security.PrivateKey; +import java.security.cert.X509Certificate; +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.EnumSet; +import java.util.HashSet; +import java.util.LinkedList; +import java.util.List; +import java.util.Objects; +import java.util.Set; + +import javax.net.ssl.X509ExtendedKeyManager; +import javax.net.ssl.X509KeyManager; + +import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; +import okhttp3.HttpUrl; + +/** + * AdvancedX509KeyManager is an implementation of X509KeyManager that handles key management, + * as well as user interaction to select an TLS client certificate, and also persist the selection. + *

+ * AdvancedX509KeyManager is based on + * InteractiveKeyManager + * created by Stephan Ritscher. + *

+ * It was stripped down to reduce it to the most relevant parts and to directly include it + * in nextcloud's android-library. (Removed features were file-based key stores and toast messages.) + * + * @author Elv1zz, elv1zz.git@gmail.com + */ +public class AdvancedX509KeyManager + extends X509ExtendedKeyManager + implements X509KeyManager +{ + private final static String TAG = AdvancedX509KeyManager.class.getName(); + private static final String NOTIFICATION_CHANNEL_ID = TAG + ".notifications"; + + private final static String DECISION_INTENT = TAG + ".DECISION"; + final static String DECISION_INTENT_ID = DECISION_INTENT + ".decisionId"; + final static String DECISION_INTENT_PORT = DECISION_INTENT + ".port"; + final static String DECISION_INTENT_HOSTNAME = DECISION_INTENT + ".hostname"; + + private final static String KEYCHAIN_ALIASES = "KeyChainAliases"; + + private SharedPreferences sharedPreferences; + + final private Context context; + + private final static int NOTIFICATION_ID = 23120; + + private static int decisionId = 0; + final private static SparseArray openDecisions = new SparseArray<>(); + + /** + * Initialize AdvancedX509KeyManager + * @param context application context (instance of Activity, Application, or Service) + */ + public AdvancedX509KeyManager(@NonNull Context context) { + super(); + this.context = context.getApplicationContext(); + init(); + } + + /** + * Perform initialization of global variables (except context) and load settings + */ + private void init() { + if (context == null) { + throw new IllegalStateException("AdvancedX509KeyManager context is null, which is not allowed!"); + } + + // Initialize settings + Log_OC.d(TAG, "init(): Loading SharedPreferences named " + context.getPackageName() + "." + "AdvancedX509KeyManager"); + sharedPreferences = context.getSharedPreferences(context.getPackageName() + "." + "AdvancedX509KeyManager", + Context.MODE_PRIVATE); + Log_OC.d(TAG, "init(): keychain aliases = " + Arrays.toString( + sharedPreferences.getStringSet(KEYCHAIN_ALIASES, new HashSet<>()).toArray())); + } + + /** + * Add KeyChain alias for use for connections to hostname:port + * @param keyChainAlias alias returned from KeyChain.choosePrivateKeyAlias + * @param hostname hostname for which the alias shall be used; null for any + * @param port port for which the alias shall be used (only if hostname is not null); null for any + * @return alias to be used in KEYCHAIN_ALIASES + */ + public @NonNull String addKeyChain(@NonNull String keyChainAlias, String hostname, + Integer port) { + String alias = new AKMAlias(KEYCHAIN, keyChainAlias, hostname, port).toString(); + Set aliases = new HashSet<>(sharedPreferences.getStringSet(KEYCHAIN_ALIASES, new HashSet<>())); + aliases.add(alias); + SharedPreferences.Editor editor = sharedPreferences.edit(); + editor.putStringSet(KEYCHAIN_ALIASES, aliases); + if (editor.commit()) { + Log_OC.d(TAG, "addKeyChain(keyChainAlias=" + keyChainAlias + ", hostname=" + hostname + ", port=" + + port + "): keychain aliases = " + Arrays.toString(aliases.toArray())); + } else { + Log_OC.e(TAG, "addKeyChain(keyChainAlias=" + keyChainAlias + ", hostname=" + hostname + ", port=" + + port + "): Could not save preferences"); + } + return alias; + } + + /** + * Remove all KeyChain and keystore aliases + */ + @SuppressWarnings("unused") + public void removeAllKeys() { + try { + removeKeyChain(new AKMAlias(KEYCHAIN, null, null, null)); + } catch (IllegalArgumentException e) { + Log_OC.e(TAG, "removeAllKeys()", e); + } + } + + /** + * Remove KeyChain aliases for connections to given host URL + * + * @param url URL for which the alias shall be removed. + */ + public void removeKeys(String url) { + try { + removeKeys(new URL(url)); + } catch(MalformedURLException e) { + Log_OC.e(TAG, "Tried to remove keys for malformed URL " + url, e); + } + } + + /** + * Remove KeyChain aliases for connections to given host URL + * + * @param url URL for which the alias shall be removed. + */ + public void removeKeys(HttpUrl url) { + removeKeys(url.url()); + } + + /** + * Remove KeyChain aliases for connections to given host URI + * + * @param uri URI for which the alias shall be removed. + */ + public void removeKeys(org.apache.commons.httpclient.URI uri) { + try { + removeKeys(uri.getURI()); + } catch (URIException e) { + Log_OC.e(TAG, "Tried to remove keys for a malformed URI", e); + } + } + + /** + * Remove KeyChain aliases for connections to given host Uri + * + * @param uri Uri for which the alias shall be removed. + */ + public void removeKeys(Uri uri) { + removeKeys(uri.toString()); + } + + /** + * Remove KeyChain aliases for connections to given host URI + * + * @param uri URI for which the alias shall be removed. + */ + public void removeKeys(URI uri) { + try { + removeKeys(uri.toURL()); + } catch (MalformedURLException e) { + Log_OC.e(TAG, "Tried to remove keys for a malformed URL", e); + } + } + + /** + * Remove KeyChain aliases for connections to given host URL + * + * @param url URL for which the alias shall be removed. + */ + public void removeKeys(URL url) { + int port = url.getPort() != -1 ? url.getPort() : url.getDefaultPort(); + removeKeys(url.getHost(), port); + } + + /** + * Remove KeyChain aliases for connections to hostname:port + * + * @param hostname hostname for which the alias shall be used; null for any + * @param port port for which the alias shall be used (only if hostname is not null); null for any + */ + @SuppressWarnings("unused") + private void removeKeys(String hostname, Integer port) { + try { + removeKeyChain(new AKMAlias(KEYCHAIN, null, hostname, port)); + } catch (IllegalArgumentException e) { + Log_OC.e(TAG, "removeKeys(hostname=" + hostname + ", port=" + port + ")", e); + } + } + + /** + * Remove KeyChain aliases from KEYCHAIN_ALIASES based on filter and depending on causing + * exception + * @param filter AKMAlias object used as filter + * @param e exception on retrieving certificate/key + */ + private void removeKeyChain(AKMAlias filter, KeyChainException e) throws IllegalArgumentException { + if (Objects.requireNonNull(e.getMessage()).contains("keystore is LOCKED")) { + /* This exception occurs after the start before the password is entered on an + encrypted device. Don't remove alias in this case. */ + return; + } + removeKeyChain(filter); + } + + /** + * Remove KeyChain aliases from KEYCHAIN_ALIASES based on filter + * @param filter AKMAlias object used as filter + */ + private void removeKeyChain(AKMAlias filter) throws IllegalArgumentException { + Set aliases = new HashSet<>(); + for (String alias : sharedPreferences.getStringSet(KEYCHAIN_ALIASES, new HashSet<>())) { + AKMAlias akmAlias = new AKMAlias(alias); + if (!akmAlias.matches(filter)) { + aliases.add(alias); + } + } + SharedPreferences.Editor editor = sharedPreferences.edit(); + editor.putStringSet(KEYCHAIN_ALIASES, aliases); + if (editor.commit()) { + Log_OC.d(TAG, "removeKeyChain(filter=" + filter + "): keychain aliases = " + + Arrays.toString(aliases.toArray())); + } else { + Log_OC.e(TAG, "removeKeyChain(filter=" + filter + "): Could not save preferences"); + } + } + + /** + * Get all KeyChain aliases matching the filter + * @param aliases collection of objects whose string representation is as returned from AKMAlias.toString() + * @param filter AKMAlias object used as filter + * @return all aliases from KEYCHAIN_ALIASES which satisfy alias.matches(filter) + */ + private static Collection filterAliases(Collection aliases, AKMAlias filter) { + Collection filtered = new LinkedList<>(); + for (Object alias : aliases) { + if (new AKMAlias(alias.toString()).matches(filter)) { + filtered.add(((String) alias)); + } + } + return filtered; + } + + /** + * Get keychain aliases for use for connections to hostname:port + * @param keyTypes accepted keyTypes; null for any + * @param issuers issuers; null for any + * @param hostname hostname of connection; null for any + * @param port port of connection; null for any + * @return array of aliases + */ + private @NonNull String[] getAliases(Set keyTypes, Principal[] issuers, String hostname, Integer port) { + // Check keychain aliases + AKMAlias filter = new AKMAlias(KEYCHAIN, null, hostname, port); + List validAliases = new LinkedList<>(filterAliases(sharedPreferences.getStringSet(KEYCHAIN_ALIASES, new HashSet<>()), filter)); + + Log_OC.d(TAG, "getAliases(keyTypes=" + (keyTypes != null ? Arrays.toString(keyTypes.toArray()) : null) + + ", issuers=" + Arrays.toString(issuers) + + ", hostname=" + hostname + + ", port=" + port + + ") = " + Arrays.toString(validAliases.toArray())); + return validAliases.toArray(new String[0]); + } + + /** + * Choose an alias for a connection, prompting for interaction if no stored alias is found + * @param keyTypes accepted keyTypes; null for any + * @param issuers accepted issuers; null for any + * @param socket connection socket + * @return keychain alias to use for this connection + */ + private String chooseAlias(String[] keyTypes, Principal[] issuers, @NonNull Socket socket) { + // Determine connection parameters + String hostname = socket.getInetAddress().getHostName(); + int port = socket.getPort(); + return chooseAlias(keyTypes, issuers, hostname, port); + } + + /** + * Choose an alias for a connection, prompting for interaction if no stored alias is found + * @param keyTypes accepted keyTypes; null for any + * @param issuers accepted issuers; null for any + * @param hostname hostname of connection + * @param port port of connection + * @return keychain alias to use for this connection + */ + private String chooseAlias(String[] keyTypes, Principal[] issuers, @NonNull String hostname, int port) { + // Select certificate for one connection at a time. This is important if multiple connections to the same host + // are started in a short time and avoids prompting the user with multiple dialogs for the same host. + synchronized (AdvancedX509KeyManager.class) { + // Get stored aliases for connection + String[] validAliases = getAliases(KeyType.parse(Arrays.asList(keyTypes)), issuers, hostname, port); + if (validAliases.length > 0) { + Log_OC.d(TAG, "chooseAlias(keyTypes=" + Arrays.toString(keyTypes) + ", issuers=" + Arrays.toString(issuers) + + ", hostname=" + hostname + ", port=" + port + ") = " + validAliases[0]); + // Return first alias found + return validAliases[0]; + } else { + Log_OC.d(TAG, "chooseAlias(keyTypes=" + Arrays.toString(keyTypes) + ", issuers=" + Arrays.toString(issuers) + + ", hostname=" + hostname + ", port=" + port + "): no matching alias found, prompting user..."); + AKMDecision decision = interactClientCert(hostname, port); + String alias; + switch (decision.state) { + case AKMDecision.DECISION_KEYCHAIN -> { // Add keychain alias for connection + alias = addKeyChain(decision.param, decision.hostname, decision.port); + Log_OC.d(TAG, "chooseAlias(keyTypes=" + Arrays.toString(keyTypes) + ", issuers=" + + Arrays.toString(issuers) + ", hostname=" + hostname + ", port=" + port + "): Use alias " + + alias); + return alias; + } + case AKMDecision.DECISION_ABORT -> { + Log_OC.w(TAG, "chooseAlias(keyTypes=" + Arrays.toString(keyTypes) + ", issuers=" + + Arrays.toString(issuers) + ", hostname=" + hostname + ", port=" + port + ") - no alias selected"); + return null; + } + default -> throw new IllegalArgumentException("Unknown decision state " + decision.state); + } + } + } + } + + @Override + public String chooseClientAlias(String[] keyTypes, Principal[] issuers, @NonNull Socket socket) { + Log_OC.d(TAG, "chooseClientAlias(keyTypes=" + Arrays.toString(keyTypes) + ", issuers=" + Arrays.toString(issuers) + ")"); + try { + return chooseAlias(keyTypes, issuers, socket); + } catch (Throwable t) { + Log_OC.e(TAG, "chooseClientAlias", t); + return null; + } + } + + @Override + public String chooseServerAlias(String keyType, Principal[] issuers, @NonNull Socket socket) { + Log_OC.d(TAG, "chooseServerAlias(keyType=" + keyType + ", issuers=" + Arrays.toString(issuers) + ")"); + return chooseAlias(new String[]{keyType}, issuers, socket); + } + + @Override + public String[] getClientAliases(String keyType, Principal[] issuers) { + Log_OC.d(TAG, "getClientAliases(keyType=" + keyType + ", issuers=" + Arrays.toString(issuers) + ")"); + return getAliases(KeyType.parse(Collections.singletonList(keyType)), issuers, null, null); + } + + @Override + public String[] getServerAliases(String keyType, Principal[] issuers) { + Log_OC.d(TAG, "getServerAliases(keyType=" + keyType + ", issuers=" + Arrays.toString(issuers) + ")"); + return getAliases(KeyType.parse(Collections.singletonList(keyType)), issuers, null, null); + } + + @Override + public X509Certificate[] getCertificateChain(@NonNull String alias) { + Log_OC.d(TAG, "getCertificateChain(alias=" + alias + ")"); + AKMAlias akmAlias = new AKMAlias(alias); + if (akmAlias.getType() == KEYCHAIN) { + try { + X509Certificate[] certificateChain = KeyChain.getCertificateChain(context, akmAlias.getAlias()); + if (certificateChain == null) { + throw new KeyChainException("could not retrieve certificate chain for alias " + akmAlias.getAlias()); + } + return certificateChain; + } catch (KeyChainException e) { + Log_OC.e(TAG, "getCertificateChain(alias=" + alias + ") - keychain alias=" + akmAlias.getAlias(), e); + removeKeyChain(akmAlias, e); + return null; + } catch (InterruptedException e) { + Log_OC.d(TAG, "getCertificateChain(alias=" + alias + ")", e); + Thread.currentThread().interrupt(); + return null; + } + } else { + throw new IllegalArgumentException("Invalid alias"); + } + } + + @Override + public PrivateKey getPrivateKey(@NonNull String alias) { + Log_OC.d(TAG, "getPrivateKey(alias=" + alias + ")"); + AKMAlias akmAlias = new AKMAlias(alias); + if (akmAlias.getType() == KEYCHAIN) { + try { + PrivateKey key = KeyChain.getPrivateKey(context, akmAlias.getAlias()); + if (key == null) { + throw new KeyChainException("could not retrieve private key for alias " + akmAlias.getAlias()); + } + return key; + } catch (KeyChainException e) { + Log_OC.e(TAG, "getPrivateKey(alias=" + alias + ")", e); + removeKeyChain(akmAlias, e); + return null; + } catch (InterruptedException e) { + Log_OC.d(TAG, "getPrivateKey(alias=" + alias + ")", e); + Thread.currentThread().interrupt(); + return null; + } + } else { + throw new IllegalArgumentException("Invalid alias"); + } + } + + @SuppressWarnings("unused") + @RequiresApi(21) + public void handleWebViewClientCertRequest(@NonNull final ClientCertRequest request) { + Log_OC.d(TAG, "handleWebViewClientCertRequest(keyTypes=" + Arrays.toString(request.getKeyTypes()) + + ", issuers=" + Arrays.toString(request.getPrincipals()) + ", hostname=" + request.getHost() + + ", port=" + request.getPort() + ")"); + new Thread() { + @Override + public void run() { + String alias = chooseAlias( + request.getKeyTypes(), + request.getPrincipals(), + request.getHost(), + request.getPort() + ); + if (alias != null) { + PrivateKey key = getPrivateKey(alias); + X509Certificate[] chain = getCertificateChain(alias); + if (key != null && chain != null) { + Log_OC.d(TAG, "handleWebViewClientCertRequest: proceed, alias = " + alias); + request.proceed(key, chain); + return; + } + } + Log_OC.d(TAG, "handleWebViewClientCertRequest: ignore, alias = " + alias); + request.ignore(); + } + }.start(); + } + + @SuppressWarnings("unused") + public void handshakeFailed(Socket socket) throws IOException { + InputStream is = socket.getInputStream(); + int len = is.available(); + byte[] buffer = new byte[len]; + is.mark(len + 1); + len = is.read(buffer, 0, len); + is.reset(); + Log_OC.e(TAG, "handshakeFailed: " + new String(buffer, 0, len, Charset.defaultCharset())); + } + + /** + * Generate a unique identifier for a decision and remember it in openDecisions + * + * @param decision decision to remember + * @return unique decision identifier + */ + private static int createDecisionId(@NonNull AKMDecision decision) { + int id; + synchronized (openDecisions) { + id = decisionId; + openDecisions.put(id, decision); + decisionId += 1; + } + return id; + } + + private void startActivityNotification(@NonNull Intent intent, int decisionId, @NonNull String message) { + int flags = 0; + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + flags |= PendingIntent.FLAG_IMMUTABLE; + } + final PendingIntent call = PendingIntent.getActivity(context, 0, intent, flags); + NotificationManagerCompat notificationManager = NotificationManagerCompat.from(context); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + NotificationChannel channel = new NotificationChannel( + NOTIFICATION_CHANNEL_ID, + context.getString(R.string.notification_channel_name), + NotificationManager.IMPORTANCE_DEFAULT); + notificationManager.createNotificationChannel(channel); + } + final Notification notification = new NotificationCompat + .Builder(context, NOTIFICATION_CHANNEL_ID) + .setContentTitle(context.getString(R.string.notification_title_select_client_cert)) + .setContentText(message) + .setTicker(message) + .setSmallIcon(android.R.drawable.ic_lock_lock) + .setWhen(System.currentTimeMillis()) + .setContentIntent(call) + .setAutoCancel(true) + .build(); + + if (ActivityCompat.checkSelfPermission(context, POST_NOTIFICATIONS) == PERMISSION_GRANTED) { + notificationManager.notify(NOTIFICATION_ID + decisionId, notification); + } else { + Log_OC.w(TAG, "Cannot send notification due to missing permission."); + } + } + + /** + * Display an Android system dialog where the user can select a client certificate for the + * connection. + * @param hostname hostname of connection + * @param port port of connection + * @return decision object with result of user interaction + */ + @SuppressFBWarnings({"UW", "WA"}) + private @NonNull AKMDecision interactClientCert(@NonNull final String hostname, final int port) { + Log_OC.d(TAG, "interactClientCert(hostname=" + hostname + ", port=" + port + ")"); + + final AKMDecision decision = new AKMDecision(); + final int id = createDecisionId(decision); + + Intent ni = new Intent(context, SelectClientCertificateHelperActivity.class); + ni.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + ni.setData(Uri.parse(SelectClientCertificateHelperActivity.class.getName() + "/" + id)); + ni.putExtra(DECISION_INTENT_ID, id); + ni.putExtra(DECISION_INTENT_HOSTNAME, hostname); + ni.putExtra(DECISION_INTENT_PORT, port); + + // we try to directly start the activity and fall back to making a notification + // e.g. when the app is in the background and we cannot just start a new activity + try { + context.startActivity(ni); + } catch (Exception e) { + Log_OC.d(TAG, "interactClientCert: startActivity(SelectClientCertificateHelperActivity)", e); + startActivityNotification(ni, id, context.getString(R.string.notification_message_select_client_cert, hostname, port)); + } + + // wait for user decision + try { + synchronized (decision) { // Lint warns that decision is local, but in fact it is persisted in openDecisions + decision.wait(); + } + } catch (InterruptedException e) { + Log_OC.d(TAG, "interactClientCert: InterruptedException", e); + Thread.currentThread().interrupt(); + } + + return decision; + } + + /** + * Callback for SelectKeyStoreActivity to set the decision result. + * @param decisionId decision identifier + * @param state type of the result as defined in IKMDecision + * @param param keychain alias respectively keystore filename + * @param hostname hostname of connection + * @param port port of connection + */ + static void interactResult(int decisionId, int state, String param, String hostname, Integer port) { + AKMDecision decision; + Log_OC.d(TAG, "interactResult(decisionId=" + decisionId + ", state=" + state + ", param=" + param + + ", hostname=" + hostname + ", port=" + port); + // Get decision object + synchronized (openDecisions) { + decision = openDecisions.get(decisionId); + openDecisions.remove(decisionId); + } + if (decision == null) { + Log_OC.e(TAG, "interactResult: aborting due to stale decision reference!"); + return; + } + // Fill in result + synchronized (decision) { // Lint warns that decision is local, but in fact it is persisted in openDecisions + decision.state = state; + decision.param = param; + decision.hostname = hostname; + decision.port = port; + decision.notify(); + } + } + + static class AKMDecision { + public final static int DECISION_INVALID = 0; + public final static int DECISION_ABORT = 1; + public final static int DECISION_KEYCHAIN = 2; + + public int state = DECISION_INVALID; + public String param; + public String hostname; + public Integer port; + } + + static class AKMAlias { + private final static String TAG = AKMAlias.class.getCanonicalName(); + + enum Type { + KEYCHAIN("KC_"), + KEYSTORE("KS_"); + + private final String prefix; + + Type(String prefix) { + this.prefix = prefix; + } + + public String getPrefix() { + return prefix; + } + + /** + * @throws IllegalArgumentException if prefix is unknown + */ + @SuppressFBWarnings("DRE") + public static Type parse(String prefix) throws IllegalArgumentException { + for (Type type : Type.values()) { + if (type.getPrefix().equals(prefix)) { + return type; + } + } + throw new IllegalArgumentException("unknown prefix"); + } + } + + private final Type type; + private final String alias; + private final String hostname; + private final Integer port; + + /** + * Constructor of AKMAlias + * + * @param type type of alias (KEYCHAIN or KEYSTORE) + * @param alias alias returned from KeyChain.choosePrivateKeyAlias respectively PrivateKey.hashCode + * @param hostname hostname for which the alias shall be used; null for any + * @param port port for which the alias shall be used (only if hostname is not null); null for any + */ + public AKMAlias(Type type, String alias, String hostname, Integer port) { + this.type = type; + this.alias = alias; + this.hostname = hostname; + this.port = port; + } + + /** + * Constructor of AKMAlias + * + * @param alias value returned from AKMAlias.toString() + */ + public AKMAlias(String alias) throws IllegalArgumentException { + String[] aliasFields = alias.split(":"); + if (aliasFields.length > 3 || aliasFields[0].length() < 4) { + throw new IllegalArgumentException("alias was not returned by AKMAlias.toString(): " + alias); + } + this.type = Type.parse(aliasFields[0].substring(0, 3)); + this.alias = aliasFields[0].substring(3); + this.hostname = aliasFields.length > 1 ? aliasFields[1] : null; + this.port = aliasFields.length > 2 ? Integer.valueOf(aliasFields[2]) : null; + } + + public Type getType() { + return type; + } + + public String getAlias() { + return alias; + } + + @SuppressWarnings("unused") + public String getHostname() { + return hostname; + } + + @SuppressWarnings("unused") + public Integer getPort() { + return port; + } + + @NonNull + @Override + public String toString() { + StringBuilder constructedAlias = new StringBuilder(); + constructedAlias.append(type.getPrefix()); + constructedAlias.append(alias); + if (hostname != null) { + constructedAlias.append(':'); + constructedAlias.append(hostname); + if (port != null) { + constructedAlias.append(':'); + constructedAlias.append(port); + } + } + return constructedAlias.toString(); + } + + @Override + public boolean equals(Object object) { + if (!(object instanceof AKMAlias other)) { + return false; + } + return Objects.equals(type, other.type) && + Objects.equals(alias, other.alias) && + Objects.equals(hostname, other.hostname) && + Objects.equals(port, other.port); + } + + @Override + public int hashCode() { + return Objects.hash(type, alias, hostname, port); + } + + /** + * @param filter AKMAlias object used as filter + * @return true if each non-null field of filter equals the same field of this instance; false otherwise + * Exception: both hostname fields are resolved to an ip address before comparing if possible. + */ + public boolean matches(@NonNull AKMAlias filter) { + boolean matches = isNullOrEqual(filter.type, type, "matches: alias " + this + " does not match type " + filter.type); + matches &= isNullOrEqual(filter.alias, alias, "matches: alias " + this + " does not match original alias " + filter.alias); + if (matches && hostname != null && filter.hostname != null && !filter.hostname.equals(hostname)) { + // Resolve hostname fields to ip addresses + InetAddress address = getInetAddressByName(hostname); + InetAddress filterAddress = getInetAddressByName(filter.hostname); + // If resolution succeeded, compare addresses, otherwise host names + if ((address == null || !address.equals(filterAddress))) { + Log_OC.d(TAG, "matches: alias " + this + " (address=" + address + ") does not match hostname " + + filter.hostname + " (address=" + filterAddress + ")"); + matches = false; + } + } + matches &= isNullOrEqual(filter.port, port, "matches: alias " + this + " does not match port " + filter.port); + return matches; + } + + private boolean isNullOrEqual(Object a, Object b, String message) { + if (a != null && !a.equals(b)) { + Log_OC.d(TAG, message); + return false; + } + return true; + } + + /** + * Try to get the address of a host according to the given hostname. + * + * @param hostname The hostname to get the address for. + * @return The InetAddress instance for the hostname or null if host is unkown. + */ + private InetAddress getInetAddressByName(String hostname) { + InetAddress address = null; + try { + address = InetAddress.getByName(hostname); + } catch (UnknownHostException e) { + Log_OC.w(TAG, "matches: error resolving " + hostname); + } + return address; + } + } + + private enum KeyType { + RSA("RSA"), + EC("EC", "ECDSA"); + + private final Set names; + + KeyType(String... names) { + this.names = new HashSet<>(Arrays.asList(names)); + } + + public Set getNames() { + return names; + } + + public static KeyType parse(String keyType) { + for (KeyType type : KeyType.values()) { + if (type.getNames().contains(keyType)) { + return type; + } + } + throw new IllegalArgumentException("unknown prefix"); + } + + public static Set parse(Iterable keyTypes) { + EnumSet keyTypeSet = EnumSet.noneOf(KeyType.class); + if (keyTypes != null) { + for (String keyType : keyTypes) { + keyTypeSet.add(parse(keyType)); + } + } + return keyTypeSet; + } + } +} diff --git a/library/src/main/java/com/owncloud/android/lib/common/network/AdvancedX509TrustManager.java b/library/src/main/java/com/owncloud/android/lib/common/network/AdvancedX509TrustManager.java index 4df0cc212..a411ba66b 100644 --- a/library/src/main/java/com/owncloud/android/lib/common/network/AdvancedX509TrustManager.java +++ b/library/src/main/java/com/owncloud/android/lib/common/network/AdvancedX509TrustManager.java @@ -79,10 +79,10 @@ public AdvancedX509TrustManager(KeyStore knownServersKeyStore) * @return The first X509TrustManager found in factory. */ private X509TrustManager findX509TrustManager(TrustManagerFactory factory) { - TrustManager tms[] = factory.getTrustManagers(); - for (int i = 0; i < tms.length; i++) { - if (tms[i] instanceof X509TrustManager) { - return (X509TrustManager) tms[i]; + TrustManager[] tms = factory.getTrustManagers(); + for (TrustManager tm : tms) { + if (tm instanceof X509TrustManager) { + return (X509TrustManager) tm; } } return null; diff --git a/library/src/main/java/com/owncloud/android/lib/common/network/NetworkUtils.java b/library/src/main/java/com/owncloud/android/lib/common/network/NetworkUtils.java index 73de79ab7..f70fad2af 100644 --- a/library/src/main/java/com/owncloud/android/lib/common/network/NetworkUtils.java +++ b/library/src/main/java/com/owncloud/android/lib/common/network/NetworkUtils.java @@ -36,7 +36,6 @@ import java.io.BufferedOutputStream; import java.io.File; import java.io.FileInputStream; -import java.io.FileOutputStream; import java.io.IOException; import java.io.InputStream; import java.security.GeneralSecurityException; @@ -48,6 +47,7 @@ import javax.net.ssl.SSLContext; import javax.net.ssl.TrustManager; +import javax.net.ssl.X509KeyManager; public class NetworkUtils { @@ -118,8 +118,10 @@ public static AdvancedSslSocketFactory getAdvancedSslSocketFactory(Context conte AdvancedX509TrustManager trustMgr = new AdvancedX509TrustManager(trustStore); TrustManager[] tms = new TrustManager[]{trustMgr}; + X509KeyManager[] kms = new X509KeyManager[]{ new AdvancedX509KeyManager(context) }; + SSLContext sslContext = getSSLContext(); - sslContext.init(null, tms, null); + sslContext.init(kms, tms, null); mHostnameVerifier = new BrowserCompatHostnameVerifier(); mAdvancedSslSocketFactory = new AdvancedSslSocketFactory(sslContext, trustMgr, mHostnameVerifier); diff --git a/library/src/main/java/com/owncloud/android/lib/common/network/SelectClientCertificateHelperActivity.java b/library/src/main/java/com/owncloud/android/lib/common/network/SelectClientCertificateHelperActivity.java new file mode 100644 index 000000000..8b8f1755d --- /dev/null +++ b/library/src/main/java/com/owncloud/android/lib/common/network/SelectClientCertificateHelperActivity.java @@ -0,0 +1,114 @@ +/* The MIT license. + +Copyright (c) 2023 Elv1zz + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. + */ +package com.owncloud.android.lib.common.network; + +import android.app.Activity; +import android.app.AlertDialog; +import android.app.Dialog; +import android.content.Intent; +import android.os.Build; +import android.security.KeyChain; +import android.security.KeyChainAliasCallback; + +import com.owncloud.android.lib.R; +import com.owncloud.android.lib.common.utils.Log_OC; + +import androidx.annotation.Nullable; + +public class SelectClientCertificateHelperActivity extends Activity implements KeyChainAliasCallback { + + private static final String TAG = SelectClientCertificateHelperActivity.class.getName(); + + private static final int REQ_CODE_INSTALL_CERTS = 1; + + private int decisionId; + private String hostname; + private int port; + + private Dialog installCertsDialog = null; + + @Override + public void onResume() { + super.onResume(); + // Load data from intent + Intent i = getIntent(); + decisionId = i.getIntExtra(AdvancedX509KeyManager.DECISION_INTENT_ID, AdvancedX509KeyManager.AKMDecision.DECISION_INVALID); + hostname = i.getStringExtra(AdvancedX509KeyManager.DECISION_INTENT_HOSTNAME); + port = i.getIntExtra(AdvancedX509KeyManager.DECISION_INTENT_PORT, -1); + Log_OC.d(TAG, "onResume() with " + i.getExtras() + " decId=" + decisionId + " data=" + i.getData()); + if (installCertsDialog == null) { + KeyChain.choosePrivateKeyAlias(this, this, null, null, null, -1, null); + } + } + + /** + * Called with the alias of the certificate chosen by the user, or null if no value was chosen. + * + * @param alias The alias of the certificate chosen by the user, or null if no value was chosen. + */ + @Override + public void alias(@Nullable String alias) { + // Show a dialog to add a certificate if no certificate was found + // API Versions < 29 still handle this automatically + if (alias == null && Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + runOnUiThread(() -> { + installCertsDialog = new AlertDialog.Builder(this) + .setTitle(R.string.title_no_client_cert) + .setMessage(R.string.message_install_client_cert) + .setPositiveButton( + android.R.string.yes, + (dialog, which) -> startActivityForResult(KeyChain.createInstallIntent(), REQ_CODE_INSTALL_CERTS) + ) + .setNegativeButton(android.R.string.no, (dialog, which) -> { + dialog.dismiss(); + sendDecision(AdvancedX509KeyManager.AKMDecision.DECISION_ABORT, null); + }) + .create(); + installCertsDialog.show(); + }); + } else { + sendDecision(AdvancedX509KeyManager.AKMDecision.DECISION_KEYCHAIN, alias); + } + } + + @Override + protected void onActivityResult(int requestCode, int resultCode, Intent data) { + if (requestCode == REQ_CODE_INSTALL_CERTS) { + installCertsDialog = null; + } else { + super.onActivityResult(requestCode, resultCode, data); + } + } + + /** + * Stop the user interaction and send result to invoking AdvancedX509KeyManager. + * + * @param state type of the result as defined in AKMDecision + * @param param keychain alias respectively keystore filename + */ + void sendDecision(int state, String param) { + Log_OC.d(TAG, "sendDecision(" + state + ", " + param + ", " + hostname + ", " + port + ")"); + AdvancedX509KeyManager.interactResult(decisionId, state, param, hostname, port); + finish(); + } +} diff --git a/library/src/main/res/values/strings.xml b/library/src/main/res/values/strings.xml new file mode 100644 index 000000000..c9d007eea --- /dev/null +++ b/library/src/main/res/values/strings.xml @@ -0,0 +1,8 @@ + + + No client certificate was found + Do you want to install a TLS client certificate? + NextCloud connection + Select client certificate + Select client certificate for %1$s:%2$d + \ No newline at end of file diff --git a/library/src/test/java/com/nextcloud/common/NextcloudClientTest.kt b/library/src/test/java/com/nextcloud/common/NextcloudClientTest.kt index 8d88e145c..8d8d8f685 100644 --- a/library/src/test/java/com/nextcloud/common/NextcloudClientTest.kt +++ b/library/src/test/java/com/nextcloud/common/NextcloudClientTest.kt @@ -63,7 +63,7 @@ class NextcloudClientTest { MockitoAnnotations.initMocks(this) val userId = "test" val credentials = Credentials.basic("login", "test") - nextcloudClient = NextcloudClient(uri, userId, credentials, okHttpClient) + nextcloudClient = NextcloudClient(uri, userId, credentials, okHttpClient, context) } @Test diff --git a/library/src/test/java/com/nextcloud/common/OkHttpMethodBaseTest.kt b/library/src/test/java/com/nextcloud/common/OkHttpMethodBaseTest.kt index 8e1272b34..db5ed6414 100644 --- a/library/src/test/java/com/nextcloud/common/OkHttpMethodBaseTest.kt +++ b/library/src/test/java/com/nextcloud/common/OkHttpMethodBaseTest.kt @@ -60,7 +60,7 @@ class OkHttpMethodBaseTest { MockitoAnnotations.initMocks(this) val userId = "test" val credentials = Credentials.basic("username", "password") - nextcloudClient = NextcloudClient(uri, userId, credentials, okHttpClient) + nextcloudClient = NextcloudClient(uri, userId, credentials, okHttpClient, context) } @Test diff --git a/library/src/test/java/com/owncloud/android/lib/common/network/AdvancedX509KeyManagerTests.kt b/library/src/test/java/com/owncloud/android/lib/common/network/AdvancedX509KeyManagerTests.kt new file mode 100644 index 000000000..40b692479 --- /dev/null +++ b/library/src/test/java/com/owncloud/android/lib/common/network/AdvancedX509KeyManagerTests.kt @@ -0,0 +1,50 @@ +package com.owncloud.android.lib.common.network + +import com.owncloud.android.lib.common.network.AdvancedX509KeyManager.AKMAlias +import org.junit.Assert +import org.junit.Test + +private const val PORT_SAME = 123 +private const val PORT_OTHER = 1234 + +private const val ALIAS_SAME = "alias" +private const val ALIAS_OTHER = "alias1" + +private const val HOST_SAME = "hostname" +private const val HOST_OTHER = "hostname1" + +class AdvancedX509KeyManagerTests { + @Test + fun testAKMAliasMatches() { + val akmAlias1 = AKMAlias(AKMAlias.Type.KEYCHAIN, ALIAS_SAME, HOST_SAME, PORT_SAME) + val akmAlias2 = AKMAlias(AKMAlias.Type.KEYCHAIN, ALIAS_SAME, HOST_SAME, PORT_SAME) + + Assert.assertTrue(akmAlias1.matches(akmAlias1)) + Assert.assertTrue(akmAlias1.matches(akmAlias2)) + + val akmAlias3 = AKMAlias(AKMAlias.Type.KEYSTORE, ALIAS_SAME, HOST_SAME, PORT_SAME) + Assert.assertFalse(akmAlias1.matches(akmAlias3)) + + val akmAlias4 = AKMAlias(AKMAlias.Type.KEYCHAIN, ALIAS_OTHER, HOST_SAME, PORT_SAME) + Assert.assertFalse(akmAlias1.matches(akmAlias4)) + + val akmAlias5 = AKMAlias(AKMAlias.Type.KEYCHAIN, ALIAS_SAME, HOST_OTHER, PORT_SAME) + Assert.assertFalse(akmAlias1.matches(akmAlias5)) + + val akmAlias6 = AKMAlias(AKMAlias.Type.KEYCHAIN, ALIAS_SAME, HOST_SAME, PORT_OTHER) + Assert.assertFalse(akmAlias1.matches(akmAlias6)) + + // parameters being null are considered "do-not-care" + val akmAlias7 = AKMAlias(null, ALIAS_SAME, HOST_SAME, PORT_SAME) + Assert.assertTrue(akmAlias1.matches(akmAlias7)) + + val akmAlias8 = AKMAlias(AKMAlias.Type.KEYCHAIN, null, HOST_SAME, PORT_SAME) + Assert.assertTrue(akmAlias1.matches(akmAlias8)) + + val akmAlias9 = AKMAlias(AKMAlias.Type.KEYCHAIN, ALIAS_SAME, null, PORT_SAME) + Assert.assertTrue(akmAlias1.matches(akmAlias9)) + + val akmAlias10 = AKMAlias(null, null, null, PORT_SAME) + Assert.assertTrue(akmAlias1.matches(akmAlias10)) + } +}