Friday, April 10, 2015

Accepting self-signed and local IP certificates with HTTPS in Java

By default, the connection will fail anytime java tries to connect via https to a server whose certificate doesn't have an issuer chain or whose issuer chain doesn't lead to a recognized issuer in the java trust store. That means if the server's certificate is self-signed it will fail. The exception message will be:

javax.net.ssl.SSLHandshakeException: sun.security.validator.ValidatorException: PKIX path building failed: sun.security.provider.certpath.SunCertPathBuilderException: unable to find valid certification path to requested target

Many people get around this by simply accepting the certificates or allowing users to accept the certificates. But this is potentially insecure because there might be a man-in-the-middle attack going on. A better approach in many cases is to allow users to provide a certificate that they trust. This can be done permanently by using keytool, a program that comes with the JRE. But if you want the option for users to trust particular certificates without adding them permanently to the java default keystore, it can be handled in code.

The following code downloads a file from a given URL. If the URL uses HTTPS, then one may optionally add an additional trusted key to the store (presumably either to the server being accessed, or to a trusted issuer which issued the certificate used by the server). This allows a secure connection to such servers. (And uses Java 7 for the file transfer).



import java.io.BufferedInputStream;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.io.InputStream;
import java.net.MalformedURLException;
import java.net.URL;
import java.net.URLConnection;
import java.nio.file.CopyOption;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.security.KeyStore;
import java.security.cert.Certificate;
import java.security.cert.CertificateException;
import java.security.cert.CertificateFactory;
import java.util.logging.Logger;
import javax.net.ssl.HostnameVerifier;
import javax.net.ssl.HttpsURLConnection;
import javax.net.ssl.SSLContext;
import javax.net.ssl.SSLSession;
import javax.net.ssl.SSLSocketFactory;
import javax.net.ssl.TrustManagerFactory;
import javax.xml.parsers.DocumentBuilderFactory;

/* 
 * Downloads from a standard URL; works with HTTP, HTTPS, and handles self-signed certificates, with no issuer chain -- in this case the certificate must 
 * be provided for security purposes.
 *  
 * Resulting file will be overwritten if it already exists.
 * 
 *  @param urlString: location of file to download
 *  @param fileName: local filename where file will be saved
 *  @param certFile: path and filename of certFile to be used if server certificate is self-signed (with no issuer path), or empty string if not needed. 
 *  @throws IOException: In case of any connection error, or the certificate doesn't exist
 *  @throws FileNotFoundException: If the file cannot be created
 * 
 */
private long downloadFileFromURL( String urlString, String fileName, String certFilePath )
throws FileNotFoundException, IOException
{          
    try {
        LOGGER.fine( "Fetching URL: " + urlString );
        URL url = new URL(urlString);
        
        URLConnection conn = url.openConnection();
        
        if ( url.getProtocol().equals( "https" ) && certFilePath!=null && !certFilePath.isEmpty() ) {
            // Server uses a self-signed certificate (with no issuer chain)
            // we require the user to provide the public key of the server (as obtained
            // from a trusted source, eg the sys admin) for verification (to 
            // ensure there is no man-in-the-middle attack)
            HttpsURLConnection sconn = (HttpsURLConnection) conn;

            try {
                /* Load the default trustedStore (normally containing root certificates) if one exists,
                 * otherwise create an empty one
                 */
                KeyStore keyStore = KeyStore.getInstance(KeyStore.getDefaultType());
                String jrePath = System.getProperty("java.home");
                String defaultTrustStorePath = jrePath + File.separator + "lib" + File.separator + "security" + File.separator;
                File certfile = new File(defaultTrustStorePath + "jssecacerts"); // first default location
                if (!certfile.isFile()) // use fall-back default location
                    certfile = new File(defaultTrustStorePath + "cacerts");
                if (!certfile.isFile()) // use no default trust store, only provided key.
                    certfile = null;
                char[] javaTruststoreDefaultPassword = "changeit".toCharArray();
                keyStore.load( certfile==null?null:new FileInputStream(certfile) , javaTruststoreDefaultPassword); 

                /* load the provided certificate and add to keystore
                 */
                FileInputStream fis = new FileInputStream(certFilePath);
                BufferedInputStream bis = new BufferedInputStream(fis);
                CertificateFactory cf = CertificateFactory.getInstance("X.509");
                while (bis.available() > 0) {
                    Certificate cert = cf.generateCertificate(bis);
                    keyStore.setCertificateEntry( "selfsignedkey", cert );
                }
                
                // Set to use this keystore when creating ssl socket
                TrustManagerFactory tmf = 
                    TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm());
                tmf.init(keyStore);
                SSLContext ctx = SSLContext.getInstance("TLS");
                ctx.init(null, tmf.getTrustManagers(), null);
                SSLSocketFactory sslFactory = ctx.getSocketFactory();

                sconn.setSSLSocketFactory(sslFactory);
                
            } catch (FileNotFoundException e) {
                LOGGER.severe( "Unable to find specified certificate file '" + certFilePath + "'!");
                //could proceed to try without having this certificate, but if they provide it better to assume it's needed.
                throw new IOException(e); // Recast as IOException (to categorize it as a connection error)
            } catch (CertificateException e) {
                LOGGER.severe( "Error loading specified certificate; certificate may be corrupt. Details:" );
                e.printStackTrace();
                throw new IOException(e);
            } catch (Exception e) { // KeyStore-related exceptions
                LOGGER.severe( "Unknown exception setting up SSL connection. Details:" );
                e.printStackTrace();
                throw new IOException(e);
            }
        }
        
        // Save the result of the HTTP request as a file
        InputStream is = null;
        File outFile = new File(fileName);
        try {
            is = conn.getInputStream();
            Path dest = outFile.toPath();
            Files.copy( is, dest, new CopyOption[]{StandardCopyOption.REPLACE_EXISTING});
             
        } finally {
            if (is!=null)  is.close();
        }
        
       LOGGER.fine( "Transmission completed, wrote " + fileName);
       return outFile.length();
       
    } catch (MalformedURLException e) {
        LOGGER.severe( "Invalid URL requested: " + urlString);
        throw e; // derives from IOException
    }
         
}

Another certificate problem that may occur is that the certificate makes no reference to the IP, and you want to access the site via the IP (particularly if there is no way to verify the domain, as for example the server is on a local network). You might receive the following certificate error:

javax.net.ssl.SSLHandshakeException: java.security.cert.CertificateException: No subject alternative names present

Ideally, the certificate could add the IP to the SAN (subject alternative name) field and avoid this error, but issuers are moving away from doing this. But if the server is on your local network, ie, you know the server operator, then this is not a big deal. You can simply override the error. The following code accepts certificates having this problem, so long as the requested IP is on a local (private) network. Simply place it in the static initialization block in the same class.


static {
    HttpsURLConnection.setDefaultHostnameVerifier(
        new  HostnameVerifier()
        {
            // function is called only when certificate name verification fails, to check whether to override
            public boolean verify(String hostname, SSLSession session)
            {
                // Accept local (private) network ipv4 address even if they don't match the certificate name.
                // (Ideally the IP would be put in the SAN:IP field when creating the certificate, then we wouldn't get here)
                if (hostname.startsWith("10.") ||
                    hostname.startsWith("192.168."))
                    return true;
                else if ( hostname.startsWith( "172.") && ".".equals(hostname.substring(6,7)) ) {
                    try {
                        int ipsubnet = Integer.parseInt( hostname.substring(4,6) );
                        if (ipsubnet>=16 && ipsubnet <=31)
                            return true;
                    } catch (NumberFormatException e)
                    {                          
                    }
                }
                return false;
            }
        });
}

I am indebted to some insightful stack overflow responses in developing this solution, particularly this one by erickson.