From 74d329749ac5cf4e9bff6fe5f8b846727961fcc2 Mon Sep 17 00:00:00 2001 From: Elv1zz Date: Sat, 1 Jun 2024 23:53:16 +0200 Subject: [PATCH 1/7] Added required classes to support TLS client certificates This adds classes from nextcloud's `android-library` project which are used to manage TLS client certificates and handle corresponding requests. --- android/app/src/main/AndroidManifest.xml | 2 + .../java/gallery/memories/MainActivity.kt | 34 + ...SelectClientCertificateHelperActivity.java | 96 +++ .../network/AdvancedX509KeyManager.java | 798 ++++++++++++++++++ .../memories/service/AccountService.kt | 6 +- .../gallery/memories/service/HttpService.kt | 32 +- android/app/src/main/res/values/strings.xml | 6 + 7 files changed, 969 insertions(+), 5 deletions(-) create mode 100644 android/app/src/main/java/gallery/memories/SelectClientCertificateHelperActivity.java create mode 100644 android/app/src/main/java/gallery/memories/network/AdvancedX509KeyManager.java diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index a73ba1616..85507f660 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -31,6 +31,8 @@ + + diff --git a/android/app/src/main/java/gallery/memories/MainActivity.kt b/android/app/src/main/java/gallery/memories/MainActivity.kt index 87c9726f4..7a00762ad 100644 --- a/android/app/src/main/java/gallery/memories/MainActivity.kt +++ b/android/app/src/main/java/gallery/memories/MainActivity.kt @@ -14,6 +14,7 @@ import android.view.View import android.view.WindowInsets import android.view.WindowInsetsController import android.view.WindowManager +import android.webkit.ClientCertRequest import android.webkit.CookieManager import android.webkit.PermissionRequest import android.webkit.SslErrorHandler @@ -39,6 +40,7 @@ import androidx.media3.exoplayer.ExoPlayer import androidx.media3.exoplayer.hls.HlsMediaSource import androidx.media3.exoplayer.source.ProgressiveMediaSource import gallery.memories.databinding.ActivityMainBinding +import gallery.memories.network.AdvancedX509KeyManager import java.util.concurrent.Executors @@ -214,6 +216,38 @@ class MainActivity : AppCompatActivity() { super.onReceivedSslError(view, handler, error) } } + + /** + * Handle request for a TLS client certificate. + */ + override fun onReceivedClientCertRequest(view: WebView?, request: ClientCertRequest?) { + if (view == null || request == null) { + return + } + AdvancedX509KeyManager(view.context).handleWebViewClientCertRequest(request) + } + + /** + * Handle HTTP errors. + * + * We might receive an HTTP status code 400 (bad request), which probably tells us that our certificate + * is not valid (anymore), e.g. because it expired. In that case we forget the selected client certificate, + * so it can be re-selected. + */ + override fun onReceivedHttpError( + view: WebView?, + request: WebResourceRequest?, + errorResponse: WebResourceResponse? + ) { + val errorCode = errorResponse?.statusCode ?: return + if (errorCode == 400) { + Log.w(TAG, "WebView failed with error code $errorCode; remove key chain aliases") + // chosen client certificate alias does not seem to work -> discard it + val failingUrl = request?.url ?: return + val context = view?.context ?: return + AdvancedX509KeyManager(context).removeKeys(failingUrl) + } + } } // Use the web chrome client to handle file uploads diff --git a/android/app/src/main/java/gallery/memories/SelectClientCertificateHelperActivity.java b/android/app/src/main/java/gallery/memories/SelectClientCertificateHelperActivity.java new file mode 100644 index 000000000..94b15053f --- /dev/null +++ b/android/app/src/main/java/gallery/memories/SelectClientCertificateHelperActivity.java @@ -0,0 +1,96 @@ +/* + * Nextcloud Android Library + * + * SPDX-FileCopyrightText: 2023 Elv1zz + * SPDX-License-Identifier: MIT + */ +package gallery.memories; + +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 android.util.Log; +import androidx.annotation.Nullable; +import gallery.memories.network.AdvancedX509KeyManager; + +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.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.d(TAG, "sendDecision(" + state + ", " + param + ", " + hostname + ", " + port + ")"); + AdvancedX509KeyManager.interactResult(decisionId, state, param, hostname, port); + finish(); + } +} diff --git a/android/app/src/main/java/gallery/memories/network/AdvancedX509KeyManager.java b/android/app/src/main/java/gallery/memories/network/AdvancedX509KeyManager.java new file mode 100644 index 000000000..340ead1e4 --- /dev/null +++ b/android/app/src/main/java/gallery/memories/network/AdvancedX509KeyManager.java @@ -0,0 +1,798 @@ +/* + * Nextcloud Android Library + * + * SPDX-FileCopyrightText: 2023 Elv1zz + * SPDX-FileCopyrightText: 2016-2022 Stephan Ritscher + * SPDX-License-Identifier: MIT + */ +package gallery.memories.network; + +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.security.KeyChain; +import android.security.KeyChainException; +import android.util.Log; +import android.util.SparseArray; +import android.webkit.ClientCertRequest; +import androidx.annotation.NonNull; +import androidx.core.app.ActivityCompat; +import androidx.core.app.NotificationCompat; +import androidx.core.app.NotificationManagerCompat; +import gallery.memories.R; +import gallery.memories.SelectClientCertificateHelperActivity; +import okhttp3.HttpUrl; + +import javax.net.ssl.X509ExtendedKeyManager; +import javax.net.ssl.X509KeyManager; +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 static android.Manifest.permission.POST_NOTIFICATIONS; +import static android.content.pm.PackageManager.PERMISSION_GRANTED; +import static gallery.memories.network.AdvancedX509KeyManager.AKMAlias.Type.KEYCHAIN; + +/** + * 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"; + public final static String DECISION_INTENT_ID = DECISION_INTENT + ".decisionId"; + public final static String DECISION_INTENT_PORT = DECISION_INTENT + ".port"; + public 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.d(TAG, "init(): Loading SharedPreferences named " + context.getPackageName() + "." + "AdvancedX509KeyManager"); + sharedPreferences = context.getSharedPreferences(context.getPackageName() + "." + "AdvancedX509KeyManager", + Context.MODE_PRIVATE); + Log.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.d(TAG, "addKeyChain(keyChainAlias=" + keyChainAlias + ", hostname=" + hostname + ", port=" + + port + "): keychain aliases = " + Arrays.toString(aliases.toArray())); + } else { + Log.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.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.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(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.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.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.d(TAG, "removeKeyChain(filter=" + filter + "): keychain aliases = " + + Arrays.toString(aliases.toArray())); + } else { + Log.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.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.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.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.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.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.d(TAG, "chooseClientAlias(keyTypes=" + Arrays.toString(keyTypes) + ", issuers=" + Arrays.toString(issuers) + ")"); + try { + return chooseAlias(keyTypes, issuers, socket); + } catch (Throwable t) { + Log.e(TAG, "chooseClientAlias", t); + return null; + } + } + + @Override + public String chooseServerAlias(String keyType, Principal[] issuers, @NonNull Socket socket) { + Log.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.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.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.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.e(TAG, "getCertificateChain(alias=" + alias + ") - keychain alias=" + akmAlias.getAlias(), e); + removeKeyChain(akmAlias, e); + return null; + } catch (InterruptedException e) { + Log.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.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.e(TAG, "getPrivateKey(alias=" + alias + ")", e); + removeKeyChain(akmAlias, e); + return null; + } catch (InterruptedException e) { + Log.d(TAG, "getPrivateKey(alias=" + alias + ")", e); + Thread.currentThread().interrupt(); + return null; + } + } else { + throw new IllegalArgumentException("Invalid alias"); + } + } + + @SuppressWarnings("unused") + public void handleWebViewClientCertRequest(@NonNull final ClientCertRequest request) { + Log.d(TAG, "handleWebViewClientCertRequest(keyTypes=" + Arrays.toString(request.getKeyTypes()) + + ", issuers=" + Arrays.toString(request.getPrincipals()) + ", hostname=" + request.getHost() + + ", port=" + request.getPort() + ")"); + new Thread(() -> { + 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.d(TAG, "handleWebViewClientCertRequest: proceed, alias = " + alias); + request.proceed(key, chain); + return; + } + } + Log.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.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 = PendingIntent.FLAG_IMMUTABLE; + final PendingIntent call = PendingIntent.getActivity(context, 0, intent, flags); + NotificationManagerCompat notificationManager = NotificationManagerCompat.from(context); + 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.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 + */ + private @NonNull AKMDecision interactClientCert(@NonNull final String hostname, final int port) { + Log.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.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.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 + */ + public static void interactResult(int decisionId, int state, String param, String hostname, Integer port) { + AKMDecision decision; + Log.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.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(); + } + } + + public 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 + */ + 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.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.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.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/android/app/src/main/java/gallery/memories/service/AccountService.kt b/android/app/src/main/java/gallery/memories/service/AccountService.kt index 995944093..cb5cd9466 100644 --- a/android/app/src/main/java/gallery/memories/service/AccountService.kt +++ b/android/app/src/main/java/gallery/memories/service/AccountService.kt @@ -25,7 +25,7 @@ class AccountService(private val mCtx: MainActivity, private val mHttp: HttpServ */ fun login(url: String, trustAll: Boolean) { try { - mHttp.build(url, trustAll) + mHttp.build(mCtx, url, trustAll) val res = mHttp.getApiDescription() if (res.code != 200) { @@ -200,7 +200,7 @@ class AccountService(private val mCtx: MainActivity, private val mHttp: HttpServ fun deleteCredentials() { store.deleteCredentials() mHttp.setAuthHeader(null) - mHttp.build(null, false) + mHttp.build(mCtx, null, false) } /** @@ -208,7 +208,7 @@ class AccountService(private val mCtx: MainActivity, private val mHttp: HttpServ */ fun refreshCredentials() { val cred = store.getCredentials() ?: return - mHttp.build(cred.url, cred.trustAll) + mHttp.build(mCtx, cred.url, cred.trustAll) mHttp.setAuthHeader(Pair(cred.username, cred.token)) } diff --git a/android/app/src/main/java/gallery/memories/service/HttpService.kt b/android/app/src/main/java/gallery/memories/service/HttpService.kt index f9513443c..256d499c9 100644 --- a/android/app/src/main/java/gallery/memories/service/HttpService.kt +++ b/android/app/src/main/java/gallery/memories/service/HttpService.kt @@ -1,10 +1,12 @@ package gallery.memories.service import android.annotation.SuppressLint +import android.content.Context import android.net.Uri import android.util.Base64 import android.webkit.CookieManager import android.webkit.WebView +import gallery.memories.network.AdvancedX509KeyManager import okhttp3.MediaType.Companion.toMediaTypeOrNull import okhttp3.OkHttpClient import okhttp3.Request @@ -12,11 +14,13 @@ import okhttp3.RequestBody.Companion.toRequestBody import okhttp3.Response import org.json.JSONArray import org.json.JSONObject +import java.security.KeyStore import java.security.SecureRandom import java.security.cert.CertificateException import java.security.cert.X509Certificate import javax.net.ssl.HttpsURLConnection import javax.net.ssl.SSLContext +import javax.net.ssl.TrustManagerFactory import javax.net.ssl.X509TrustManager @@ -50,7 +54,7 @@ class HttpService { * @param url The URL to use * @param trustAll Whether to trust all certificates */ - fun build(url: String?, trustAll: Boolean) { + fun build(context: Context, url: String?, trustAll: Boolean) { mBaseUrl = url mTrustAll = trustAll client = if (trustAll) { @@ -60,8 +64,32 @@ class HttpService { .hostnameVerifier { _, _ -> true } .build() } else { - OkHttpClient() + val standardX509TrustManager = findStandardX509TrustManager() + if (standardX509TrustManager != null) { + val sslContext = SSLContext.getInstance("TLS") + val keyManager = AdvancedX509KeyManager(context) + sslContext.init(arrayOf(keyManager), arrayOf(standardX509TrustManager), null) + OkHttpClient.Builder() + .sslSocketFactory(sslContext.socketFactory, standardX509TrustManager) + .build() + } else { + // when there is no X509TrustManager available, we cannot perform TLS client authorization + OkHttpClient() + } + } + } + + private fun findStandardX509TrustManager() : X509TrustManager? { + val factory = TrustManagerFactory + .getInstance(TrustManagerFactory.getDefaultAlgorithm()) + factory.init(null as KeyStore?) + val tms = factory.trustManagers + for (tm in tms) { + if (tm is X509TrustManager) { + return tm + } } + return null } /** diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index 53b3f98a4..f954e69e2 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -17,4 +17,10 @@ Your server does not have the minimum required version of Memories Logged out from server Failed to connect to server. Reset app data if this persists. + + + + + + \ No newline at end of file From 70d0de42477c3829906671ec2cd17b22f0eb624a Mon Sep 17 00:00:00 2001 From: Elv1zz Date: Sat, 1 Jun 2024 23:55:39 +0200 Subject: [PATCH 2/7] Added button to perform client certificate selection Because of the different communication paths of the *memories* app (using "local" API calls resulting in network requests), the selection of the TLS client certificate has to be done in a different way than in the *nextcloud* app. *If* the login request would be the first request sent to the server using the webview, things might work out differently, but since the API describe endpoint is queried first, certificate selection has not been done so far, resulting in a connection error. By using an additional step opening the user entered URL in the webview, we can trigger the certificate selection and afterwards use the selected certificate for the real authentication. --- android/app/src/main/assets/welcome.html | 29 +++++++++++++++++-- .../src/main/java/gallery/memories/NativeX.kt | 13 +++++++++ 2 files changed, 40 insertions(+), 2 deletions(-) diff --git a/android/app/src/main/assets/welcome.html b/android/app/src/main/assets/welcome.html index c1ba1a3ac..7523b1a42 100644 --- a/android/app/src/main/assets/welcome.html +++ b/android/app/src/main/assets/welcome.html @@ -32,6 +32,10 @@ + + @@ -45,6 +49,8 @@