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

Add client certificate support (https://github.com/gotify/android/issues/229) #230

Open
wants to merge 5 commits into
base: master
Choose a base branch
from

Conversation

polizz
Copy link

@polizz polizz commented Apr 25, 2022

So a few things to point out:

  • The @RequiresApi(api = Build.VERSION_CODES.O) annotations were added because Android studio complained about using some of the base64 classes. Everything appeared to run without them, but I'm not sure if it's best practice to not have them.

  • The 3 client cert settings added were to keep the advanced settings dialog in line with how it currently behaves - there's no way to guarantee being able to show a name based on the DN of a self-created cert, so I show the file name of the actual picked cert file.

  • CertUtils.java:104 will catch the correct exception when the password for the cert is incorrect although it is hacky having to check the exception text (not sure why it's such a generic exception type). With that said, the current way the exception handling is routing and the snackbar messages are constructed, this message will never be shown to the user. They will get another message about an error 0, which I believe the SSL connection failing to handshake.

Ideally, with a refactor of the advanced settings screen, the user could test the client cert password before committing the login information permanently to settings.

Let me know what you think and if you want anything tweaked.

@@ -100,6 +105,7 @@ private void invalidateUrl() {
checkUrlButton.setText(getString(R.string.check_url));
}

@RequiresApi(api = Build.VERSION_CODES.O)
Copy link
Member

@jmattheis jmattheis Apr 25, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This method must not have the RequiredApi annotation, wrap the client cert code into an if similar to

if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
NotificationSupport.createChannels(
(NotificationManager) this.getSystemService(Context.NOTIFICATION_SERVICE));
}

The client cert options inside the advanced settings dialog should be hidden / disabled if Build.VERSION.SDK_INT < Build.VERSION_CODES.O

Currently, if an android version less than Build.VERSION_CODES.O is used, then the app will crash when base64 stuff is accessed, because these classes don't exist in older android versions.

(only briefly looked at the PR, will look into your remarks later this week)

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok, those guards have been put into place. Tested on 4a API 30 and 3 API 24 and everything works against my server with and without client certificates respectively.

Comment on lines 243 to 244
settings.clientCert(clientCertContents);
settings.clientCertUri(path);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why set the settings here? Now you have clientCertContents and settings.clientCert which have the same value. Values should only be set on the settings inside com.github.gotify.login.LoginActivity#onCreatedClient

Also the settings.clientCert won't be unset on remove client certificate, so the setting/variable become out of sync.

.onClickRemoveClientCertificate(() -> {
    clientCertContents = null;
    settings.clientCertPass("");
    clientCertPassword = "";
    settings.clientCertUri(null);
    clientCertUri = null;
})

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Resetting the setting values in that handler is done because I thought it was similar to the existing onClickRemoveCaCertificate above reset the ca cert contents on removal.

I will add the missing clientCert reset.

} catch (Exception e) {

if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
if (settings.clientCert != null) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I couldn't get client certificates working together with a self-signed server certificate, could you test if this works correctly. Maybe the SSLContext gets overridden because

SSLContext context = SSLContext.getInstance("TLS");
context.init(...);

is called two times.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, the problem was I was not adding the client cert to the key store for branch related to self-signed certs at all. It now tries to add the client cert to that as well if possible. I've retested against a real cert and it's still working. I am having issues configuring my docker gotify for a self-signed cert (I currently have an installation that uses caddy and real certs). If there's anyway you can retest with your setup in the meantime, it would be appreciated.

}

new AlertDialog.Builder(context)
.setView(dialogView)
.setTitle(R.string.advanced_settings)
.setPositiveButton(context.getString(R.string.done), (ignored, ignored2) -> {})
.setPositiveButton(context.getString(R.string.done), (ignored, ignored2) -> {
settings.clientCertPass(holder.editClientCertPass.getText().toString());
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Settings should only be set inside com.github.gotify.login.LoginActivity#onCreatedClient

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is my first android code, so help me here. The other buttons on the advanced screen start file selection activities that end up being handled by an onActivityResult in LoginActivity.java. I was not sure how to get the value of the client cert password except through a lambda that is run after the done button is clicked. Do you have another way? I do see settings being modified in other functions besides onCreatedClient.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm running out of things to try in creating the self-signed certificate. I've tried a couple of different things and the LOG on the login page says Hostname .... not verified. I've tried selecting my CA root and the server cert and I can see that it is trying to validate the CA cert. This is all on the master branch by the way.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I use caddy for testing the self-signed certificate thingy in gotify (it works for me on master and your branch)

Caddyfile:

{
  http_port 8000
  https_port 8443
}
192.168.178.2 {
  tls internal
  reverse_proxy localhost:8080
}

gotify/server listens on localhost:8080, my desktop PC is 192.168.178.2. When starting caddy run -config Caddyfile, then Caddy will create certificates at ~/.local/share/caddy/certificates/local/192.168.178.2 and the crt is located at 192.168.178.2.crt.

In gotify I'll use https://192.168.178.2:8443 as server url. This error should be logged, when the ca .crt file isn't configured inside gotify, but the connection works after the cert is configured.

Exception
2022-05-16T07:26:07.679Z ERROR: Error while api call
Code(0) Response: 
	at com.github.gotify.api.Callback$RetrofitCallback.onFailure(Callback.java:80)
	at retrofit2.ExecutorCallAdapterFactory$ExecutorCallbackCall$1$2.run(ExecutorCallAdapterFactory.java:80)
	at android.os.Handler.handleCallback(Handler.java:883)
	at android.os.Handler.dispatchMessage(Handler.java:100)
	at android.os.Looper.loop(Looper.java:214)
	at android.app.ActivityThread.main(ActivityThread.java:7356)
	at java.lang.reflect.Method.invoke(Native Method)
	at com.android.internal.os.RuntimeInit$MethodAndArgsCaller.run(RuntimeInit.java:492)
	at com.android.internal.os.ZygoteInit.main(ZygoteInit.java:930)
Caused by: javax.net.ssl.SSLHandshakeException: java.security.cert.CertPathValidatorException: Trust anchor for certification path not found.
	at com.android.org.conscrypt.ConscryptFileDescriptorSocket.startHandshake(ConscryptFileDescriptorSocket.java:231)
	at okhttp3.internal.connection.RealConnection.connectTls(RealConnection.java:319)
	at okhttp3.internal.connection.RealConnection.establishProtocol(RealConnection.java:283)
	at okhttp3.internal.connection.RealConnection.connect(RealConnection.java:168)
	at okhttp3.internal.connection.StreamAllocation.findConnection(StreamAllocation.java:257)
	at okhttp3.internal.connection.StreamAllocation.findHealthyConnection(StreamAllocation.java:135)
	at okhttp3.internal.connection.StreamAllocation.newStream(StreamAllocation.java:114)
	at okhttp3.internal.connection.ConnectInterceptor.intercept(ConnectInterceptor.java:42)
	at okhttp3.internal.http.RealInterceptorChain.proceed(RealInterceptorChain.java:147)
	at okhttp3.internal.http.RealInterceptorChain.proceed(RealInterceptorChain.java:121)
	at okhttp3.internal.cache.CacheInterceptor.intercept(CacheInterceptor.java:93)
	at okhttp3.internal.http.RealInterceptorChain.proceed(RealInterceptorChain.java:147)
	at okhttp3.internal.http.RealInterceptorChain.proceed(RealInterceptorChain.java:121)
	at okhttp3.internal.http.BridgeInterceptor.intercept(BridgeInterceptor.java:93)
	at okhttp3.internal.http.RealInterceptorChain.proceed(RealInterceptorChain.java:147)
	at okhttp3.internal.http.RetryAndFollowUpInterceptor.intercept(RetryAndFollowUpInterceptor.java:126)
	at okhttp3.internal.http.RealInterceptorChain.proceed(RealInterceptorChain.java:147)
	at okhttp3.internal.http.RealInterceptorChain.proceed(RealInterceptorChain.java:121)
	at okhttp3.RealCall.getResponseWithInterceptorChain(RealCall.java:254)
	at okhttp3.RealCall$AsyncCall.execute(RealCall.java:200)
	at okhttp3.internal.NamedRunnable.run(NamedRunnable.java:32)
	at java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1167)
	at java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:641)
	at java.lang.Thread.run(Thread.java:919)
Caused by: java.security.cert.CertificateException: java.security.cert.CertPathValidatorException: Trust anchor for certification path not found.
	at com.android.org.conscrypt.TrustManagerImpl.checkTrustedRecursive(TrustManagerImpl.java:658)
	at com.android.org.conscrypt.TrustManagerImpl.checkTrustedRecursive(TrustManagerImpl.java:617)
	at com.android.org.conscrypt.TrustManagerImpl.checkTrusted(TrustManagerImpl.java:507)
	at com.android.org.conscrypt.TrustManagerImpl.checkTrusted(TrustManagerImpl.java:426)
	at com.android.org.conscrypt.TrustManagerImpl.getTrustedChainForServer(TrustManagerImpl.java:354)
	at android.security.net.config.NetworkSecurityTrustManager.checkServerTrusted(NetworkSecurityTrustManager.java:94)
	at android.security.net.config.RootTrustManager.checkServerTrusted(RootTrustManager.java:89)
	at com.android.org.conscrypt.Platform.checkServerTrusted(Platform.java:224)
	at com.android.org.conscrypt.ConscryptFileDescriptorSocket.verifyCertificateChain(ConscryptFileDescriptorSocket.java:407)
	at com.android.org.conscrypt.NativeCrypto.SSL_do_handshake(Native Method)
	at com.android.org.conscrypt.NativeSsl.doHandshake(NativeSsl.java:387)
	at com.android.org.conscrypt.ConscryptFileDescriptorSocket.startHandshake(ConscryptFileDescriptorSocket.java:226)
	... 23 more
Caused by: java.security.cert.CertPathValidatorException: Trust anchor for certification path not found.
	... 35 more

I still cannot get the client certificate working with my setup, I get a 421 misdirected redirect from caddy. But I don't really know what I'm doing there, never used client certificates.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I will try Caddy's internal CA next. I was creating a local CA and a server cert and signing it manually and configuring Caddy to use that.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok this is what worked for me:

I was getting all my cert files confused and was trying to use the wrong file for the client cert. Also, by using the Caddy auto-cert I am getting around some apparent issue with how I was trying to manually create the CA and server cert (a problem for another day). In my case, the client cert and client CA are both separately generated and totally unrelated to the server cert (Caddy autogen cert or otherwise).

Commands to create the client certificate:

openssl req -x509 -newkey rsa:2048 -keyout gotify_client_cert.key -out gotify_client_cert.crt -days 3000
openssl req -new -key gotify_client_cert.key -out gotify_client_cert.csr
openssl x509 -req -days 3000 -in gotify_client_cert.csr -signkey gotify_client_cert.key -out gotify_client_cert-CA.crt
cat gotify_client_cert.crt gotify_client_cert.key > gotify_client_cert.pem
openssl pkcs12 -export -out gotify_client_cert.p12 -inkey gotify_client_cert.key -in gotify_client_cert.pem

Caddyfile:

(require_client_cert) {
    tls {
        client_auth {
            mode require_and_verify
            trusted_ca_cert_file /client_certs/gotify_client_cert-CA.crt
            trusted_leaf_cert_file /client_certs/gotify_client_cert.crt
        }
    }
}

* *.home.lan {
    import require_client_cert
    tls internal
    reverse_proxy http://gotify:80
}

Docker compose file:

version: '3.3'
services:
  caddy:
    image: caddy:2.4.6-alpine
    ports:
      - "3080:80"
      - "3443:443"
    volumes:
      - ./Caddyfile:/etc/caddy/Caddyfile
      - ./.ssh:/client_certs
  gotify:
    image: gotify/server:2.1.4
volumes:
  caddy_data1:

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'll have a look at this on the weekend.

})
.onClickRemoveCaCertificate(
() -> {
invalidateUrl();
caCertContents = null;
})
.show(disableSSLValidation, selectedCertName);
.onClickSelectClientCertificate(() ->
doSelectCertificate(R.string.select_client_cert_file, CLI_CERT_FILE_SELECT_CODE))
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Call invalidateUrl before selecting a certificate.

.onClickSelectClientCertificate(() ->
doSelectCertificate(R.string.select_client_cert_file, CLI_CERT_FILE_SELECT_CODE))
.onClickRemoveClientCertificate(() -> {
clientCertContents = null;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Call invalidateUrl here too.

Comment on lines 104 to 112
} catch (IOException iex) {
String tx = iex.toString();
if (iex.toString().contains("wrong password")) {
Log.e("Incorrect client certificate password.", iex);
return;
}

Log.e("Error opening client certificate.", iex);
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This catch can be omitted, because the exception message is already sufficient and says that the password is wrong / the certificate is invalid.

@@ -77,6 +78,20 @@ public void onPrepareLoad(Drawable placeHolderDrawable) {}
};
}

public static String binaryFileToBase64(@NonNull InputStream inputStream) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This method needs a @RequiresApi annotation, see build error.

The usage of this method then must be wrapped into an if inside com.github.gotify.login.LoginActivity#onActivityResult.

Comment on lines +93 to +100
TrustManagerFactory trustManagerFactory = TrustManagerFactory.getInstance(
TrustManagerFactory.getDefaultAlgorithm());
trustManagerFactory.init((KeyStore) null);
TrustManager[] trustManagers = trustManagerFactory.getTrustManagers();
if (trustManagers.length != 1 || !(trustManagers[0] instanceof X509TrustManager)) {
throw new IllegalStateException("Unexpected default trust managers:");
}
X509TrustManager trustManager = (X509TrustManager) trustManagers[0];
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why is this check needed, couldn't we pass all the trust managers into context.init(keyManagers, trustManagers, null);?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I believe that was in some of the code I used as reference. I will check those calls and if there's always a default trust manager, we could remove it.

@cyb3rko
Copy link
Contributor

cyb3rko commented Mar 18, 2024

Should we try to implement this again?
As the codebase has changed a lot (especially because of the transition to Kotlin), it would be the best idea to start from scratch and only take inspiration from this PR.

@jmattheis
Copy link
Member

Yeah, this feature is useful, it was also requested in a reddit thread somewhere.

@cyb3rko
Copy link
Contributor

cyb3rko commented Apr 17, 2024

I've now worked a bit on this.
The Android client side should be mostly implemented, but I'm really struggling with setting up the reverse proxy (Caddy) with Gotify.

My current setup responds with 308, according to curl with what seems a redirection loop:

oci-main.cloud.cyb3rko:2015 {
        tls internal
        reverse_proxy localhost:80
}

where gotify runs on port 80.

* Connection #0 to host oci-main.cloud.cyb3rko left intact
* Issue another request to this URL: 'https://oci-main.cloud.cyb3rko:2015/'
...
* Using Stream ID: 3 (easy handle 0x5d9238dbdeb0)
* TLSv1.2 (OUT), TLS header, Supplemental data (23):
> GET / HTTP/2
> Host: oci-main.cloud.cyb3rko:2015
> user-agent: curl/7.81.0
> accept: */*
> 
* TLSv1.2 (IN), TLS header, Supplemental data (23):
< HTTP/2 308 
< date: Wed, 17 Apr 2024 13:49:57 GMT
< location: https://oci-main.cloud.cyb3rko:2015/
< server: Caddy
< server: Caddy
< content-length: 0
< 
* Connection #0 to host oci-main.cloud.cyb3rko left intact
* Issue another request to this URL: 'https://oci-main.cloud.cyb3rko:2015/'
...
* Using Stream ID: 5 (easy handle 0x5d9238dbdeb0)
* TLSv1.2 (OUT), TLS header, Supplemental data (23):
> GET / HTTP/2
> Host: oci-main.cloud.cyb3rko:2015
> user-agent: curl/7.81.0
> accept: */*

I do not have any experience with reverse proxies, any ideas?

@jmattheis
Copy link
Member

Are you accessing the https port? The doubled server header

< server: Caddy
< server: Caddy

seems suspicious, is the request proxied twice? You can try enable debug logging and look what caddy is saying.

$ cat Caddyfile
{
  http_port 8000
  https_port 8443
  log {
        level  DEBUG
  }
}

dyne.local:2015 {
        reverse_proxy localhost:8080
        tls internal
}
$ curl -k https://dyne.local:2015/health -v
* Host dyne.local:2015 was resolved.
* IPv6: (none)
* IPv4: 192.168.178.2
*   Trying 192.168.178.2:2015...
* Connected to dyne.local (192.168.178.2) port 2015
* ALPN: curl offers h2,http/1.1
* TLSv1.3 (OUT), TLS handshake, Client hello (1):
* TLSv1.3 (IN), TLS handshake, Server hello (2):
* TLSv1.3 (IN), TLS handshake, Encrypted Extensions (8):
* TLSv1.3 (IN), TLS handshake, Certificate (11):
* TLSv1.3 (IN), TLS handshake, CERT verify (15):
* TLSv1.3 (IN), TLS handshake, Finished (20):
* TLSv1.3 (OUT), TLS change cipher, Change cipher spec (1):
* TLSv1.3 (OUT), TLS handshake, Finished (20):
* SSL connection using TLSv1.3 / TLS_AES_128_GCM_SHA256 / x25519 / id-ecPublicKey
* ALPN: server accepted h2
* Server certificate:
*  subject: [NONE]
*  start date: Apr 17 18:17:59 2024 GMT
*  expire date: Apr 18 06:17:59 2024 GMT
*  issuer: CN=Caddy Local Authority - ECC Intermediate
*  SSL certificate verify result: unable to get local issuer certificate (20), continuing anyway.
*   Certificate level 0: Public key type EC/prime256v1 (256/128 Bits/secBits), signed using ecdsa-with-SHA256
*   Certificate level 1: Public key type EC/prime256v1 (256/128 Bits/secBits), signed using ecdsa-with-SHA256
* TLSv1.3 (IN), TLS handshake, Newsession Ticket (4):
* using HTTP/2
* [HTTP/2] [1] OPENED stream for https://dyne.local:2015/health
* [HTTP/2] [1] [:method: GET]
* [HTTP/2] [1] [:scheme: https]
* [HTTP/2] [1] [:authority: dyne.local:2015]
* [HTTP/2] [1] [:path: /health]
* [HTTP/2] [1] [user-agent: curl/8.7.1]
* [HTTP/2] [1] [accept: */*]
> GET /health HTTP/2
> Host: dyne.local:2015
> User-Agent: curl/8.7.1
> Accept: */*
>
* Request completely sent off
< HTTP/2 200
< alt-svc: h3=":2015"; ma=2592000
< content-type: application/json; charset=utf-8
< date: Wed, 17 Apr 2024 18:19:29 GMT
< server: Caddy
< content-length: 37
<
* Connection #0 to host dyne.local left intact
{"health":"green","database":"green"}%

@cyb3rko
Copy link
Contributor

cyb3rko commented Apr 18, 2024

Nevermind, I got it working (incl. client side certificate authentication).
I've found some issues in the implementation, I will try to figure it out and continue in a new PR.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Development

Successfully merging this pull request may close these issues.

None yet

3 participants