Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Issue #1169: Support for TLS client certificates #1216

Open
wants to merge 8 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 7 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
1 change: 1 addition & 0 deletions android/app/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ dependencies {
implementation "androidx.media3:media3-exoplayer:$media_version"
implementation "androidx.media3:media3-ui:$media_version"
implementation "androidx.media3:media3-exoplayer-hls:$media_version"
implementation "androidx.media3:media3-datasource-okhttp:$media_version"

implementation "androidx.room:room-runtime:$room_version"
annotationProcessor "androidx.room:room-compiler:$room_version"
Expand Down
2 changes: 2 additions & 0 deletions android/app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@
</intent-filter>
</activity>

<activity android:name=".SelectClientCertificateHelperActivity" />

<receiver
android:name=".service.DownloadBroadcastReceiver"
android:exported="true">
Expand Down
28 changes: 26 additions & 2 deletions android/app/src/main/assets/welcome.html
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,10 @@
</label>
</div>

<button class="m-button" id="client-cert">
Select client certificate
</button>

<button class="m-button login-button" id="login">
Continue to Login
</button>
Expand All @@ -45,6 +49,8 @@
<script>
const urlBox = document.getElementById("server-url");
const loginButton = document.getElementById("login");
const clientCertButton = document.getElementById("client-cert");
const trustAllCheckbox = document.getElementById("trust-all");

function validateUrl(url) {
try {
Expand All @@ -66,7 +72,10 @@
}

function updateLoginEnabled() {
loginButton.disabled = !validateUrl(getUrl());
const validUrl = validateUrl(getUrl());
loginButton.disabled = !validUrl;
const trustAll = trustAllCheckbox.checked;
clientCertButton.disabled = !validUrl || trustAll;
}

function getMemoriesUrl() {
Expand Down Expand Up @@ -106,7 +115,7 @@
const encUrl = encodeURIComponent(encodeURIComponent(getMemoriesUrl().toString()));

// Trust all certificates
const trustAll = document.getElementById("trust-all").checked ? "1" : "0";
const trustAll = trustAllCheckbox.checked ? "1" : "0";

await fetch(`http://127.0.0.1/api/login/${encUrl}?trustAll=${trustAll}`, {
method: "GET",
Expand All @@ -123,6 +132,21 @@
}
});

// ClientCert button click handler
clientCertButton.addEventListener("click", async () => {
try {
const url = getUrl();
// go to URL to have WebView and AdvancedX509KeyManager
// handle certificate selection
globalThis.nativex?.requestClientCertFor(url)
} catch (e) {
}
});

trustAllCheckbox.addEventListener("click", async() => {
updateLoginEnabled();
});

// Set action bar color
const themeColor = getComputedStyle(
document.documentElement
Expand Down
40 changes: 37 additions & 3 deletions android/app/src/main/java/gallery/memories/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -34,11 +35,12 @@ import androidx.media3.common.PlaybackException
import androidx.media3.common.Player
import androidx.media3.common.util.UnstableApi
import androidx.media3.datasource.DefaultDataSource
import androidx.media3.datasource.DefaultHttpDataSource
import androidx.media3.datasource.okhttp.OkHttpDataSource
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


Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -331,9 +365,9 @@ class MainActivity : AppCompatActivity() {
// Add cookies from webview to data source
val cookies = CookieManager.getInstance().getCookie(uri.toString())
val httpDataSourceFactory =
DefaultHttpDataSource.Factory()
OkHttpDataSource.Factory(nativex.http.client)
.setDefaultRequestProperties(mapOf("cookie" to cookies))
.setAllowCrossProtocolRedirects(true)
// .setAllowCrossProtocolRedirects(true)
val dataSourceFactory =
DefaultDataSource.Factory(this, httpDataSourceFactory)

Expand Down
14 changes: 14 additions & 0 deletions android/app/src/main/java/gallery/memories/NativeX.kt
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import android.webkit.WebResourceRequest
import android.webkit.WebResourceResponse
import android.widget.Toast
import androidx.media3.common.util.UnstableApi
import gallery.memories.network.AdvancedX509KeyManager
import gallery.memories.service.AccountService
import gallery.memories.service.DownloadService
import gallery.memories.service.HttpService
Expand Down Expand Up @@ -183,6 +184,19 @@ class NativeX(private val mCtx: MainActivity) {
}
}

@JavascriptInterface
fun requestClientCertFor(url: String) {
Log.v(TAG, "requestClientCertFor: url=$url")
// TODO: URL sanity check! (no script, etc.)
// drop old certificate
AdvancedX509KeyManager(mCtx).removeKeys(url)
// navigate WebView to given URL to handle requests for
// TLS client certificate
mCtx.runOnUiThread {
mCtx.binding.webview.loadUrl(url)
}
}

fun handleRequest(request: WebResourceRequest): WebResourceResponse {
val path = request.url.path ?: return makeErrorResponse()

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
/*
* Nextcloud Android Library
*
* SPDX-FileCopyrightText: 2023 Elv1zz <elv1zz.git@gmail.com>
* 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();
}
}
Loading