Commit c342cbd8 authored by Ricki Hirner's avatar Ricki Hirner

Initial commit

parents
build/
cert4android.iml
apply plugin: 'com.android.library'
android {
compileSdkVersion 24
buildToolsVersion "24.0.1"
defaultConfig {
minSdkVersion 9
targetSdkVersion 24
}
}
dependencies {
compile 'com.android.support:appcompat-v7:24.+'
compile 'com.android.support:cardview-v7:24.+'
}
\ No newline at end of file
android.library=true
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="at.bitfire.cert4android">
<application>
<service android:name=".CustomCertService"/>
<activity
android:name=".TrustCertificateActivity"
android:label="@string/certificate_notification_connection_security"
android:launchMode="singleInstance"
android:excludeFromRecents="true"/>
</application>
</manifest>
/*
* Copyright © Ricki Hirner (bitfire web engineering).
* All rights reserved. This program and the accompanying materials
* are made available under the terms of the GNU Public License v3.0
* which accompanies this distribution, and is available at
* http://www.gnu.org/licenses/gpl.html
*/
package at.bitfire.cert4android;
import android.support.annotation.NonNull;
import android.support.annotation.Nullable;
import java.security.KeyStore;
import java.security.KeyStoreException;
import java.security.NoSuchAlgorithmException;
import java.security.cert.X509Certificate;
import java.util.logging.Level;
import javax.net.ssl.TrustManager;
import javax.net.ssl.TrustManagerFactory;
import javax.net.ssl.X509TrustManager;
public class CertUtils {
@Nullable
public static X509TrustManager getTrustManager(@Nullable KeyStore keyStore) {
try {
TrustManagerFactory tmf = TrustManagerFactory.getInstance("X509");
tmf.init(keyStore);
for (TrustManager trustManager : tmf.getTrustManagers())
if (trustManager instanceof X509TrustManager)
return (X509TrustManager)trustManager;
} catch(NoSuchAlgorithmException|KeyStoreException e) {
Constants.log.log(Level.SEVERE, "Couldn't initialize trust manager", e);
}
return null;
}
@NonNull
public static String getTag(@NonNull X509Certificate cert) {
StringBuilder sb = new StringBuilder();
for (byte b: cert.getSignature())
sb.append(Integer.toHexString(b & 0xFF));
return sb.toString();
}
}
/*
* Copyright © Ricki Hirner (bitfire web engineering).
* All rights reserved. This program and the accompanying materials
* are made available under the terms of the GNU Public License v3.0
* which accompanies this distribution, and is available at
* http://www.gnu.org/licenses/gpl.html
*/
package at.bitfire.cert4android;
import java.util.logging.Logger;
public class Constants {
public static Logger log = Logger.getLogger("cert4android");
public static int NOTIFICATION_CERT_DECISION = 88809;
}
/*
* Copyright © Ricki Hirner (bitfire web engineering).
* All rights reserved. This program and the accompanying materials
* are made available under the terms of the GNU Public License v3.0
* which accompanies this distribution, and is available at
* http://www.gnu.org/licenses/gpl.html
*/
package at.bitfire.cert4android;
import android.content.ComponentName;
import android.content.Context;
import android.content.Intent;
import android.content.ServiceConnection;
import android.os.Bundle;
import android.os.Handler;
import android.os.HandlerThread;
import android.os.IBinder;
import android.os.Looper;
import android.os.Message;
import android.os.Messenger;
import android.os.RemoteException;
import android.support.annotation.NonNull;
import android.support.annotation.Nullable;
import android.util.SparseArray;
import java.io.Closeable;
import java.security.cert.Certificate;
import java.security.cert.CertificateException;
import java.security.cert.X509Certificate;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.logging.Level;
import javax.net.ssl.HostnameVerifier;
import javax.net.ssl.SSLPeerUnverifiedException;
import javax.net.ssl.SSLSession;
import javax.net.ssl.X509TrustManager;
/**
* TrustManager to handle custom certificates. Communicates with
* {@link CustomCertService} to fetch information about custom certificate
* trustworthiness. The IPC with a service is required when multiple processes,
* each of them with an own {@link CustomCertManager}, want to access a synchronized central
* certificate trust store + UI (for accepting certificates etc.).
*
* @author Ricki Hirner
*/
public class CustomCertManager implements X509TrustManager, Closeable {
/** how log to wait for a decision from {@link CustomCertService} */
protected static final int SERVICE_TIMEOUT = 5*60*1000;
final Context context;
/** for sending requests to {@link CustomCertService} */
Messenger service;
/** to receive replies from {@link CustomCertService} */
final Messenger messenger;
final AtomicInteger nextDecisionID = new AtomicInteger();
final SparseArray<Boolean> decisions = new SparseArray<>();
final Object decisionLock = new Object();
/** system-default trust store */
final X509TrustManager systemTrustManager;
/** Whether to launch {@link TrustCertificateActivity} directly. The notification will always be shown. */
public boolean appInForeground = true;
ServiceConnection serviceConnection = new ServiceConnection() {
@Override
public void onServiceConnected(ComponentName className, IBinder binder) {
Constants.log.fine("Connected to service");
service = new Messenger(binder);
}
@Override
public void onServiceDisconnected(ComponentName className) {
service = null;
}
};
/**
* Creates a new instance.
* @param context used to bind to {@link CustomCertService}
* @param trustSystemCerts whether to trust system/user-installed CAs (default trust store)
*/
public CustomCertManager(@NonNull Context context, boolean trustSystemCerts) {
this.context = context;
systemTrustManager = trustSystemCerts ? CertUtils.getTrustManager(null) : null;
HandlerThread thread = new HandlerThread("CustomCertificateManagerMessenger");
thread.start();
messenger = new Messenger(new Handler(thread.getLooper(), new MessageHandler()));
if (!context.bindService(new Intent(context, CustomCertService.class), serviceConnection, Context.BIND_AUTO_CREATE))
throw new IllegalArgumentException("Couldn't bind service to this context");
}
@Override
public void close() {
if (serviceConnection != null)
context.unbindService(serviceConnection);
}
@Override
public void checkClientTrusted(X509Certificate[] chain, String authType) throws CertificateException {
throw new CertificateException("cert4android doesn't validate client certificates");
}
/**
* Checks whether a certificate is trusted. If {@link #systemTrustManager} is null (because
* system certificates are not being trusted or available), the first certificate in the chain
* (which is the lowest one, i.e. the actual server certificate) is passed to
* {@link CustomCertService} for further decision.
* @param chain certificate chain to check
* @param authType authentication type (ignored)
* @throws CertificateException in case of an untrusted or questionable certificate
*/
@Override
public void checkServerTrusted(X509Certificate[] chain, String authType) throws CertificateException {
boolean trusted = false;
if (systemTrustManager != null)
try {
systemTrustManager.checkServerTrusted(chain, authType);
trusted = true;
} catch(CertificateException e) {
Constants.log.fine("Certificate not trusted by system");
}
if (!trusted)
checkCustomTrusted(chain[0]);
}
protected void checkCustomTrusted(X509Certificate cert) throws CertificateException {
Constants.log.fine("Querying custom certificate trustworthiness");
Message msg = Message.obtain();
msg.what = CustomCertService.MSG_CHECK_TRUSTED;
int id = msg.arg1 = nextDecisionID.getAndIncrement();
msg.replyTo = messenger;
Bundle data = new Bundle();
data.putSerializable(CustomCertService.MSG_DATA_CERTIFICATE, cert);
data.putBoolean(CustomCertService.MSG_DATA_APP_IN_FOREGROUND, appInForeground);
msg.setData(data);
try {
service.send(msg);
} catch(RemoteException ex) {
throw new CertificateException("Couldn't query custom certificate trustworthiness", ex);
}
// wait for a reply
long startTime = System.currentTimeMillis();
synchronized(decisionLock) {
while (System.currentTimeMillis() < startTime + SERVICE_TIMEOUT) {
try {
decisionLock.wait(SERVICE_TIMEOUT);
} catch(InterruptedException ex) {
throw new CertificateException("Trustworthiness check interrupted", ex);
}
Boolean decision = decisions.get(id);
if (decision != null) {
decisions.delete(id);
if (decision)
// certificate trusted
return;
else
throw new CertificateException("Certificate not trusted");
}
}
// timeout occurred
throw new CertificateException("Timeout when waiting for certificate trustworthiness decision");
}
}
@Override
public X509Certificate[] getAcceptedIssuers() {
return new X509Certificate[0];
}
// custom methods
public HostnameVerifier hostnameVerifier(@Nullable HostnameVerifier defaultVerifier) {
return new CustomHostnameVerifier(defaultVerifier);
}
public void resetCertificates() {
Intent intent = new Intent(context, CustomCertService.class);
intent.setAction(CustomCertService.CMD_RESET_CERTIFICATES);
context.startService(intent);
}
// Messenger for receiving replies from CustomCertificateService
public static final int MSG_CERTIFICATE_DECISION = 0;
// arg1: id
// arg2: 1: trusted, 0: not trusted
private class MessageHandler implements Handler.Callback {
@Override
public boolean handleMessage(Message msg) {
Constants.log.fine("Received reply from CustomCertificateService: " + msg);
switch (msg.what) {
case MSG_CERTIFICATE_DECISION:
synchronized(decisionLock) {
decisions.put(msg.arg1, msg.arg2 != 0);
decisionLock.notifyAll();
}
return true;
}
return false;
}
}
// hostname verifier
protected class CustomHostnameVerifier implements HostnameVerifier {
final HostnameVerifier defaultVerifier;
public CustomHostnameVerifier(HostnameVerifier defaultVerifier) {
this.defaultVerifier = defaultVerifier;
}
@Override
public boolean verify(String host, SSLSession sslSession) {
Constants.log.fine("Verifying certificate for " + host);
if (defaultVerifier != null && defaultVerifier.verify(host, sslSession))
return true;
// default hostname verifier couldn't verify the hostname →
// accept the hostname as verified only if the certificate has been accepted be the user
try {
Certificate[] cert = sslSession.getPeerCertificates();
if (cert instanceof X509Certificate[] && cert.length > 0) {
checkCustomTrusted((X509Certificate)cert[0]);
Constants.log.fine("Certificate is in custom trust store, accepting");
return true;
}
} catch(SSLPeerUnverifiedException e) {
Constants.log.log(Level.WARNING, "Couldn't get certificate for host name verification", e);
} catch (CertificateException ignored) {
}
return false;
}
}
}
/*
* Copyright © Ricki Hirner (bitfire web engineering).
* All rights reserved. This program and the accompanying materials
* are made available under the terms of the GNU Public License v3.0
* which accompanies this distribution, and is available at
* http://www.gnu.org/licenses/gpl.html
*/
package at.bitfire.cert4android;
import android.app.Notification;
import android.app.NotificationManager;
import android.app.PendingIntent;
import android.app.Service;
import android.content.Context;
import android.content.Intent;
import android.os.Bundle;
import android.os.Handler;
import android.os.IBinder;
import android.os.Message;
import android.os.Messenger;
import android.os.RemoteException;
import android.support.v4.app.NotificationManagerCompat;
import android.support.v7.app.NotificationCompat;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileNotFoundException;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.lang.ref.WeakReference;
import java.security.KeyStore;
import java.security.KeyStoreException;
import java.security.NoSuchAlgorithmException;
import java.security.cert.CertificateException;
import java.security.cert.X509Certificate;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Map;
import java.util.Set;
import java.util.logging.Level;
import javax.net.ssl.X509TrustManager;
public class CustomCertService extends Service {
protected static final String CMD_CERTIFICATION_DECISION = "certDecision";
protected static final String EXTRA_CERTIFICATE = "certificate";
protected static final String EXTRA_TRUSTED = "trusted";
protected static final String CMD_RESET_CERTIFICATES = "resetCertificates";
public static final String
KEYSTORE_DIR = "KeyStore",
KEYSTORE_NAME = "KeyStore.bks";
File keyStoreFile;
KeyStore trustedKeyStore;
X509TrustManager customTrustManager;
Set<X509Certificate> untrustedCerts = new HashSet<>();
final Map<X509Certificate, Set<ReplyInfo>> pendingDecisions = new HashMap<>();
@Override
public void onCreate() {
keyStoreFile = new File(getDir(KEYSTORE_DIR, Context.MODE_PRIVATE), KEYSTORE_NAME);
try {
trustedKeyStore = KeyStore.getInstance(KeyStore.getDefaultType());
InputStream is;
try {
is = new FileInputStream(keyStoreFile);
} catch(FileNotFoundException e) {
Constants.log.fine("No custom keystore found");
is = null;
}
trustedKeyStore.load(is, null);
customTrustManager = CertUtils.getTrustManager(trustedKeyStore);
} catch(KeyStoreException|NoSuchAlgorithmException|CertificateException|IOException e) {
Constants.log.log(Level.SEVERE, "Couldn't initialize key store", e);
}
}
boolean inTrustStore(X509Certificate cert) {
try {
return trustedKeyStore.getCertificateAlias(cert) != null;
} catch(KeyStoreException e) {
Constants.log.log(Level.WARNING, "Couldn't query custom key store", e);
return false;
}
}
// started service
@Override
public int onStartCommand(Intent intent, int flags, int id) {
Constants.log.fine("Received command:" + intent);
switch (intent.getAction()) {
case CMD_CERTIFICATION_DECISION:
onReceiveDecision(
(X509Certificate)intent.getSerializableExtra(EXTRA_CERTIFICATE),
intent.getBooleanExtra(EXTRA_TRUSTED, false)
);
break;
case CMD_RESET_CERTIFICATES:
untrustedCerts.clear();
try {
for (String alias : Collections.list(trustedKeyStore.aliases()))
trustedKeyStore.deleteEntry(alias);
saveKeyStore();
} catch(KeyStoreException e) {
Constants.log.log(Level.SEVERE, "Couldn't reset custom certificates", e);
}
}
return START_NOT_STICKY;
}
protected void onReceiveDecision(X509Certificate cert, boolean trusted) {
// remove notification
NotificationManager nm = (NotificationManager)getSystemService(NOTIFICATION_SERVICE);
nm.cancel(CertUtils.getTag(cert), Constants.NOTIFICATION_CERT_DECISION);
// put into trust store, if trusted
if (trusted) {
untrustedCerts.remove(cert);
try {
trustedKeyStore.setCertificateEntry(cert.getSubjectDN().getName(), cert);
} catch(KeyStoreException e) {
Constants.log.log(Level.SEVERE, "Couldn't add certificate into key store", e);
}
saveKeyStore();
} else
untrustedCerts.add(cert);
// notify receivers which are waiting for a decision
Set<ReplyInfo> receivers = pendingDecisions.get(cert);
if (receivers != null) {
for (ReplyInfo receiver : receivers) {
Message message = Message.obtain();
message.what = CustomCertManager.MSG_CERTIFICATE_DECISION;
message.arg1 = receiver.id;
message.arg2 = trusted ? 1 : 0;
try {
receiver.messenger.send(message);
} catch(RemoteException e) {
Constants.log.log(Level.WARNING, "Couldn't forward decision to CustomCertManager", e);
}
}
pendingDecisions.remove(cert);
}
}
protected void saveKeyStore() {
try {
Constants.log.fine("Saving custom certificate key store to " + keyStoreFile);
OutputStream os = new FileOutputStream(keyStoreFile);
trustedKeyStore.store(os, null);
} catch(IOException|KeyStoreException|NoSuchAlgorithmException|CertificateException e) {
Constants.log.log(Level.SEVERE, "Couldn't save custom certificate key store", e);
}
}
// bound service; Messenger for IPC
public static final int MSG_CHECK_TRUSTED = 1;
public static final String
MSG_DATA_CERTIFICATE = "certificate",
MSG_DATA_APP_IN_FOREGROUND ="appInForeground";
final Messenger messenger = new Messenger(new MessageHandler(this));
@Override
public IBinder onBind(Intent intent) {
return messenger.getBinder();
}
protected static class MessageHandler extends Handler {
private final WeakReference<CustomCertService> serviceRef;
MessageHandler(CustomCertService service) {
serviceRef = new WeakReference<CustomCertService>(service);
}
@Override
public void handleMessage(Message msg) {
CustomCertService service = serviceRef.get();
if (service == null) {
Constants.log.warning("Couldn't handle message: service not available");
return;
}
Constants.log.info("Handling request: " + msg);
int id = msg.arg1;
Bundle data = msg.getData();
ReplyInfo replyInfo = new ReplyInfo(msg.replyTo, id);
switch (msg.what) {
case MSG_CHECK_TRUSTED:
X509Certificate cert = (X509Certificate)data.getSerializable(MSG_DATA_CERTIFICATE);
Set<ReplyInfo> reply = service.pendingDecisions.get(cert);
if (reply != null) {
// there's already a pending decision for this certificate, just add this reply messenger
reply.add(replyInfo);
} else {
/* no pending decision for this certificate:
1. check whether it's known as trusted or non-trusted – in this case, send a reply instantly
2. otherwise, create a pending decision
*/
if (service.untrustedCerts.contains(cert)) {
Constants.log.fine("Certificate is cached as untrusted");
try {
msg.replyTo.send(obtainMessage(CustomCertManager.MSG_CERTIFICATE_DECISION, id, 0));
} catch(RemoteException e) {
Constants.log.log(Level.WARNING, "Couldn't send distrust information to CustomCertManager", e);
}
} else if (service.inTrustStore(cert)) {
try {
msg.replyTo.send(obtainMessage(CustomCertManager.MSG_CERTIFICATE_DECISION, id, 1));
} catch(RemoteException e) {
Constants.log.log(Level.WARNING, "Couldn't send trust information to CustomCertManager", e);
}