среда, 27 мая 2015 г.

Https в Android: делаем правильно

To что я опишу ниже - не открытие и не тайна. Это есть даже в официальной документации. Проблема в том, что большинство программистов под Android (и я в том числе) пришли из других, более старых платформ, где проблемы с ssl решались иначе или не возникали вообще.
Например, если сертификат вашего сервера почему-то не в порядке (например он самоподписной), то решение отключить проверку сертификата напрашивается само собой. Такое себе "быстрое решение". В результате вроде бы https и все круто, но на публичном wifi ваш клиент рискует пообщаться по "защищенному" каналу с мошенником. Или, к примеру, вы подключаетесь по ip к своему серверу, а в сертификате прописан домен. Отключаем проверку домена? Давайте не будем спешить. Есть решение которое в "особых" случаях не только не снижает безопасность соединения вашего приложения с сервером но и повышает её до воистину параноидального уровня.

Совсем чуть-чуть теории...

Когда мы устанавливаем https-соединение с сервером, происходит такое себе "рукопожатие", в процессе которого клиент получает публичный ключ сервера. Этот ключ используется для шифрования трафика и для проверки подлинности сервера. Как убедиться что сервер действительно тот, за кого себя выдает? Сертификат, который он предоставляет клиентскому приложению должен быть заверен организацией, чей сертификат есть в системе Android как доверенный и в нём должно быть "зашито" имя домена, соответствующее тому, к которому мы обращаемся. Если мы отключаем проверку, то любой сертификат любого мошенника становится валидным, и вы отправляете ему данные. Он же может общаться с вами, проксируя ваши данные на ваш сервер, который ни о чем не подозревая, обслуживает его как нормального клиента.
Если у вас нет заверенного сертификата, вы можете сгенерировать его себе самостоятельно. Это бесплатно. Но после этого ваше приложение должно доверять этому сертификату "без опоры на авторитеты". Это можно сделать двумя способами: быстрым и правильным. Быстрый - отключить проверку. Правильный: "вшить" публичный ключ вашего сервера в клиентское приложение. Так вы сможете доверять соединению с вашим сервером, не доверяя больше никому, даже самым "супернадежным" сертификатам.

И практика...

Допустим, наш сервер telebank.ru. Он https, причем его сертификат заверен GeoTrust, поэтому мы все ему доверяем. Но допустим, мы хотим принимать его не потому что он заверен кем-то а потому, что он "вшит" в наше приложение. Тогда даже конец света не помешает нам установить защищенное соединение ;)

1. Получим свой публичный ключ:
openssl s_client -connect telebank.ru:443
Из того, что приехало в ответ берём строчки начиная с ---BEGIN CERTIFICATE--- и заканчивая ---END CERTIFICATE---, и сохраняем их в файл, к примеру в telebank.ru.cer. Это и будет наш публичный ключ в base64 представлении.

2. Чтобы работать с ключом, сделаем ему хранилище с помощью BouncyCastle Provider. Скачать его можно, например тут. Скачиваем библиотеку, кладём её рядом с нашим файлом сертификата и выполняем:
keytool -importcert -v -trustcacerts -file "telebank.ru.cer" -alias TelebankCA -keystore "tlkeystore.bks" -provider org.bouncycastle.jce.provider.BouncyCastleProvider -providerpath "bcprov-jdk16-145.jar" -storetype BKS -storepass password
Кeytool спросит вас, доверяете ли вы этому сертификату, отвечайте "yes" и получите файл хранилища tlkeystore.bks, который мы положим в res/raw нашего приложения. 

3. Дальше - переходим к программированию.
Делам простое Activity, которое будет слать запрос и выводить результат подключения:

import android.app.Activity;
import android.os.Bundle;
import android.view.Gravity;
import android.view.ViewGroup;
import android.widget.LinearLayout;
import android.widget.TextView;
import org.apache.http.HttpResponse;
import org.apache.http.HttpStatus;
import org.apache.http.client.methods.HttpGet;
 
import java.io.IOException;
 
public class MyActivity extends Activity {
 
    private TextView rez;
 
    @Override
    public void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        rez = new TextView(this);
        rez.setGravity(Gravity.CENTER);
        LinearLayout root = new LinearLayout(this);
        root.setGravity(Gravity.CENTER);
        root.addView(rez, new LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT));
        setContentView(root, new LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT));
    }
 
    @Override
    protected void onResume() {
        super.onResume();
        rez.setText("Wait, connecting...");
        new Thread(new Runnable() {
            @Override
            public void run() {
                try {
                    HttpResponse resp = new SHttpClient(MyActivity.this).execute(new HttpGet("https://telebank.ru/content/telebank-client/ru/login.html"));
                    int status = resp.getStatusLine().getStatusCode();
                    if (HttpStatus.SC_OK != status) {
                        throw new IOException("Invalid status code: " + status);
                    }
                    runOnUiThread(new Runnable() {
                        @Override
                        public void run() {
                            rez.setText("Done!");
                        }
                    });
                } catch (final Exception e) {
                    runOnUiThread(new Runnable() {
                        @Override
                        public void run() {
                            rez.setText("Error: " + e.getMessage());
                        }
                    });
                    e.printStackTrace();
                }
            }
        }).start();
    }
}

И, собственно, наш класс, расширяющий стандартный DefaultHttpClient:

import android.content.Context;
import org.apache.http.conn.ClientConnectionManager;
import org.apache.http.conn.scheme.PlainSocketFactory;
import org.apache.http.conn.scheme.Scheme;
import org.apache.http.conn.scheme.SchemeRegistry;
import org.apache.http.conn.ssl.SSLSocketFactory;
import org.apache.http.impl.client.DefaultHttpClient;
import org.apache.http.impl.conn.tsccm.ThreadSafeClientConnManager;
 
import java.io.InputStream;
import java.security.KeyStore;
 
public class SHttpClient extends DefaultHttpClient {
 
    private static final String KEYSTORE_PASS = "password";
    private KeyStore keyStore;
 
    public SHttpClient(Context ctx) throws Exception {
        InputStream in = null;
        try {
            keyStore = KeyStore.getInstance("BKS");
             in = ctx.getResources().openRawResource(R.raw.tlkeystore);
            keyStore.load(in, KEYSTORE_PASS.toCharArray());
        } finally {
            if (in != null) in.close();
        }
    }
 
    @Override
    protected ClientConnectionManager createClientConnectionManager() {
        SchemeRegistry registry = new SchemeRegistry();
        registry.register(new Scheme("http", PlainSocketFactory.getSocketFactory(), 80));
        registry.register(new Scheme("https", getSslSocketFactory(), 443));
        return new ThreadSafeClientConnManager(getParams(), registry);
    }
 
    private SSLSocketFactory getSslSocketFactory() {
        try {
            SSLSocketFactory sf = new SSLSocketFactory(keyStore);
            sf.setHostnameVerifier(SSLSocketFactory.STRICT_HOSTNAME_VERIFIER);
            return sf;
        } catch (Exception e) {
            throw new AssertionError(e);
        }
    }
}

Тут подробнее: в конструкторе вычитываем наш keystore-файл в обьект класс KeyStore. В метод  load кроме собственно файла передаем пароль, который указали при генерации хранилища (-storepass password, помните?). Передаём KeyStore в конструктор SSLSocketFactory, которую в свою очередь используем для создания Scheme и т.д. 

Проверим?

Не забудьте добавить разрешение на выход в интернет в манифесте и запустите приложение. 
С сертификатом, полученным с telebank.ru на url https://telebank.ru/content/telebank-client/ru/login.html подключение установится нормально. Попробуем установить соединение с любым другим https-сервером и получим исключение "No peer certificate". Т.е. мы запретили нашему приложению конектиться к любым (даже вроде бы сертифицированным серверам). Что это нам даёт кроме удовлетворения паранойи? Мы может смело отключить проверку имени хоста, указав:
sf.setHostnameVerifier(SSLSocketFactory.ALLOW_ALL_HOSTNAME_VERIFIER);
если вдруг возникла идея подключаться к серверу по ip. Проверьте, посылая запросы на https://217.14.50.139/content/telebank-client/ru/login.html. В то же время другие хосты остаются запрещёнными.
В целом плюсы такого подхода очевидны. Мы берём в свои руки процесс установки соединения со своим сервером, обеспечивая себе "свободу манёвра" и даже прирост скорости не жертвуя при этом безопасностью.  Минус только один. Если сертификат вашего сервера будет заменён, все клиенты должны будут обновлять приложение.

6 комментариев:

  1. Конгениальное решение! Особенно когда завтра у сертификата telebank.ru кончится срок или его перевыпустят по любой другой причине - ваше супергениальное решение немедленно перестанет работать. И пока вас не задолбают благодарные пользователи и вы не перевошьёте другой сертификат - не начнёт. С совершенно валидным сетификатом, подписанным доверенной организацией.

    Ваша задача решается куда проще - вы смотрите, доверен ли сертификат и на какой домен он выдан. И неваши домены игнорите. Так вы и используете механизмы подписанных (доверенных) сертификатов, и не разрешаете своему ;) приложению ходить налево.

    ОтветитьУдалить
    Ответы
    1. Внимательно читая пост, можно заметить что решение предлагается как раз для "особых" случаев, когда сертификат нельзя или нет резона использовать традиционным способом.
      Например, ваш сервер используется только как точка доступа для запросов мобильного клиента и не обслуживает браузер в принципе. Зачем покупать для него "совершенно валидный сетификат, подписанный доверенной организацией"?
      На счет замены сертификата - этот минус описан, но по-моему замена сертификата раз в несколько лет не должна быть проблемой для мобильного приложения, которое штатно обновляется раз в 2-3 недели.
      Ваше решение с проверкой домена - хорошее, но это решение другой задачи. Не пускать приложение "налево" в рамках описанного в посте метода скорее бонус, чем главная цель.

      Удалить
  2. Так ведь defaulthttpclient теперь deprecated

    ОтветитьУдалить
  3. привет!
    вот а что делать есть wsdl на https. по вашему методу достучаться не получается. что есть ловлю javax.net.ssl.SSLPeerUnverifiedException: No peer certificate. вроде бы все норм. опен ссл testsaite.com:443 а в url testsaite.com/......../asd?wsdl

    ОтветитьУдалить
  4. Thanks for sharing, nice post! Post really provice useful information!

    Giaonhan247 chuyên dịch vụ mua hàng mỹ từ dịch vụ order hàng mỹ hay nhận mua nước hoa pháp từ website nổi tiếng hàng đầu nước Mỹ mua hàng ebay ship về VN uy tín, giá rẻ.

    ОтветитьУдалить