Борьба за безопасность при создании менеджера паролей под Android

Рассказывает Михаил Иванов, наш подписчик 


Приветствую всех! Хочу поделиться историей создания своего первого проекта под Android, а также рассказать об основных проблемах, которые возникли при работе над отдельными его компонентами.

Зачем нужен еще один менеджер паролей?

В Play Market достаточно приложений для хранения паролей и заметок в зашифрованном виде, но для себя я так и не нашел сочетания всех необходимых функций в одном приложении.

В принципе, с хранением логинов и паролей различных интернет-сайтов отлично справляется любой браузер, в том числе и на телефоне. Проблемы начинаются, когда, например, по работе нужно сохранить в безопасном месте пароли от компьютеров, маршрутизаторов или другого оборудования. В таком случае придется искать надежное и удобное место для всего этого добра, и телефон подойдет как нельзя лучше.

Анализ существующих приложений

Что мне не понравилось во многих самых популярных приложениях, так это необходимость регистрировать еще один аккаунт, собственно, для самого приложения. Еще не всегда в приложении есть возможность отказаться от синхронизации с облаком, а в редких случаях нельзя даже редактировать данные, находясь оффлайн.

Зачастую в менеджерах паролей присутствует еще один существенный недостаток: невозможность сделать скрытыми несколько полей, например, пароль и пин-код или пароль и ответ на секретный вопрос. Тогда приходится выбирать, что важнее спрятать от несанкционированного доступа. Неудобства часто доставляет и ограниченное количество полей, которые можно добавить в запись, или когда их количество вообще статично и нельзя убрать незаполненные поля.

Различные комбинации этих недостатков в существующих приложениях и побудили меня к созданию собственного менеджера паролей.

Особенности менеджера паролей PassData

Ключевые фишки моего приложения:

  • возможность создания нескольких независимых хранилищ (аккаунтов). Каждому хранилищу в приложении можно назначить свое имя, иконку, и, что самое главное, — свой мастер-пароль. Я не нашел в Play Market ни одного офлайн-менеджера паролей, где это можно сделать, возможно, потому что это не очень востребовано, но для кого-то при выборе этот момент может стать решающим;
  • возможность создания неограниченного количества собственных категорий (папок) для группирования записей. Очевидный функционал, который, тем не менее, отсутствует в некоторых приложениях, ограничиваясь небольшим количеством предусмотренных разработчиком категорий;
  • создание неограниченного количества полей (логин, пароль и т. д.) для каждой записи, возможность настройки видимости полей и их произвольной сортировки;
  • каждому полю в записи можно присвоить один из типов, которые могут быть представлены логином, паролем, ссылкой, email, комментарием или обычным полем. У полей, которые имеют тип ссылки или email, появится кнопка для быстрого перехода или отправки письма. Поле комментария больше остальных, поэтому позволяет вводить многострочный текст;
  • большой набор иконок для записей;
  • возможность быстрого создания новой записи и набора полей для нее;
  • приложение полностью офлайновое. Такое решение было принято из-за того, что для синхронизации данных между устройствами или облачного хранения нужен свой backend, а это в данный момент не входит в мои планы. Плюс в том, что отсутствие доступа к интернету прибавляет доверия к приложению. Одна из проблем, которая при этом возникла, — это трудности при сборе аналитики и отчетов об ошибках.

О разработке

Сначала разработка велась во фрагментах/активити. Это основные компоненты Android SDK, используемые при разработке приложения. Однако размещать весь код в них, смешивая бизнес-логику и работу с операционной системой, — не очень хорошая практика, которая грозит ситуацией, когда исправить что-то или добавить новый функционал становится практически невозможно. К счастью, я вовремя столкнулся с аббревиатурой MVP.

Model View Presenter — архитектурный подход, который предполагает разделение всех классов программы на три основных слоя, каждый из которых выполняет строго свои обязанности. Пока было еще не совсем поздно, я все переписал, что однозначно того стоило и окупило себя многократно. Такой подход значительно упрощает внесение изменений в код и в целом облегчает понимание логики работы программы.

Основные технологии и подходы, которые я использовал во время разработки:

  • уже упомянутый MVP как основу архитектуры приложения;
  • Dependency Injection (DI) — набор паттернов и принципов разработки, которые позволяют писать слабосвязанный код. Google для их внедрения даже создал собственную библиотеку – Dagger 2, именно ей я и воспользовался;
  • MVP и DI позволяют писать легко тестируемый код. Благодаря разбиению на слои и слабой связанности компонентов системы, можно легко тестировать отдельные ее части. Такие тесты называются «Unit tests», и ими покрыта вся бизнес-логика в приложении;
  • система контроля версий (далее VCS) — еще один must have при разработке. Системы контроля версий позволяют хранить всю историю изменения исходного кода программы и возвращаться к любой его версии при необходимости. VCS предоставляет огромное количество различных инструментов, которые значительно упрощают разработку. Есть множество различных систем контроля версий, в своей работе я использовал Git, которая является наиболее распространенной и удобной;
  • все данные, введенные пользователем в приложение (пароли, логины и т. д.), хранятся в SQLCipher базе данных.

Реализации безопасности в приложении

Обеспечение безопасности хранения данных в подобных приложениях стоит на первом месте, поэтому я хочу рассказать, каким образом хранятся данные в приложении и как происходит авторизация пользователя. Также я расскажу о проблемах, которые возникли в ходе разработки.

Получить доступ к данным, хранящимся в приложении, можно двумя способами: введя мастер-пароль, заданный при создании хранилища, или воспользовавшись сканером отпечатков пальцев.
Вход в приложение c помощью мастер-пароля реализован достаточно просто: при создании хранилища пароль от него хешируется (соль + пароль), и этот хеш хранится в приложении. При последующем входе введенный пароль хешируется с той же солью и сравнивается с хранящимся хешем. Если они совпадают, то введенный пароль используется для доступа к SQLCipher базе данных.

Вход по отпечатку пальца

Со входом по отпечатку пальца все гораздо сложнее, особенно если учесть все нештатные ситуации. В простом виде это выглядит примерно так:

  1. На экране логина проверяем, поддерживается ли сканер отпечатков и включен ли он в настройках приложения.
  2. Проверяем, зарегистрирован ли в системе хотя бы один отпечаток пальца.
  3. Если все нормально, ждем от системы ответа об успешной аутентификации.
  4. Расшифровываем мастер-пароль и используем его для доступа к SQLCipher базе данных.
  5. Подробный алгоритм работы при входе по отпечатку показан на схеме:

Весь этот функционал в приложении возложен на два класса с говорящими названиями: FingerprintAuthenticator и Cryptographer. Они представляют собой удобные обертки над классами FingerprintManagerCompat и KeyStore/Cipher.

Код к шагам 1-3:

if (mFingerprintAuthenticator.isFingerprintSupported() & mSettingsManager.isFingerprintAuthEnabled()) {
    switch (mFingerprintAuthenticator.getStatus()) {
        case DEVICE_NOT_LOCKED:
        // Сообщение пользователю о том, что необходимо установить один из способов блокировки экрана    
            mLoginView.showFingerprintUi(FingerprintAuthenticator.FingerprintStatus.DEVICE_NOT_LOCKED);
            LoggerPassdata.i(LoggerPassdata.TAG_LOGIN_SCREEN, "Fingerprint status - DEVICE_NOT_LOCKED");
            break;
        case NO_FINGERPRINTS:
            // Сообщаем пользователю о том, что не зарегистрировано ни одного отпечатка пальцев
            mLoginView.showFingerprintUi(FingerprintAuthenticator.FingerprintStatus.NO_FINGERPRINTS);
            LoggerPassdata.i(LoggerPassdata.TAG_LOGIN_SCREEN, "Fingerprint status - NO_FINGERPRINTS");
            break; 
        case READY:
            mFingerprintAuthenticator.setAuthenticationEventsCallback(this);
            if (mFingerprintAuthenticator.startListening()) {
                mLoginView.showFingerprintUi(FingerprintAuthenticator.FingerprintStatus.READY);
                LoggerPassdata.i(LoggerPassdata.TAG_LOGIN_SCREEN, "Fingerprint status - READY. Start listening fingerprint scanner");
            } else {
                // При попытке начать прослушивать сканер отпечатков возникли проблемы, 
                //скорее всего, из-за проблем с криптографией, сообщаем об этом пользователю 
                //и удаляем существующую ключевую пару 
                //(Она будет пересоздана при следующем входе по мастер-паролю) 
                mLoginView.showFingerprintUi(FingerprintAuthenticator.FingerprintStatus.CANT_USE_FINGERPRINT);
                LoggerPassdata.i(LoggerPassdata.TAG_LOGIN_SCREEN, "Fingerprint status - READY. FAILED to start listening fingerprint scanner");
                if (mCryptographer != null) {
                    mCryptographer.recreateKeypair();
                }
            }
            break;
        default:
            IllegalArgumentException illegalArgumentException = new IllegalArgumentException("Unknown fingerprint state");
            LoggerPassdata.wtf(LoggerPassdata.TAG_LOGIN_SCREEN, "Unknown fingerprint state", illegalArgumentException);
            throw illegalArgumentException;
    }
} else {
      // Вход по отпечатку пальца не поддерживается или выключен
      // Прячем пользовательский интерфейс, относящийся к сканеру отпечатков
    mLoginView.hideFingerprintUi();
    LoggerPassdata.i(LoggerPassdata.TAG_LOGIN_SCREEN, "Fingerprint not enabled or unsupported. Hide fingerprint UI");
}

Метод, вызываемый при успешной аутентификации с помощью сканера отпечатков:

@Override
public void onAuthenticationSucceeded(Cipher result) {
    if (mLoginViewState.mSelectedStorage == null) {
        mLoginView.showLoginError(LoginContract.LoginError.INCORRECT_STORAGE);
        return;
    }
    String password;
    if (mCryptographer != null) {
        password = mCryptographer.decryptData(mLoginViewState.mSelectedStorage.getEncryptedPassword(), result);
    } else {
        password = "";
    }
    String passwordHash = Hashing.sha256().hashUnencodedChars(mLoginViewState.mSelectedStorage.getId() + password).toString();
    //Проверяем, совпадает ли хеш расшифрованного пароля с хешем, хранящимся в приложении. 
    if (Strings.isNullOrEmpty(password) || !passwordHash.equals(mLoginViewState.mSelectedStorage.getPasswordHash())) {
        //Возникли проблемы при расшифровке пароля, сообщаем об этом пользователю 
        //и просим войти по мастер-паролю (пароль будет зашифрован заново). 
        mLoginViewState.mSelectedStorage.setEncryptedPassword("");
        mLoginView.showLoginError(LoginContract.LoginError.CANT_USE_FINGERPRINT_AUTHENTICATION);
    } else {
        grantAccess(password, false);
    }
}

Первые проблемы в данной части начинаются с аутентификации при помощи сканера отпечатков пальцев. Дело в том, что даже если сканер отпечатков доступен, у нас может не получиться начать прослушивать его из-за проблем в работе KeyStore и Cipher. Именно поэтому я получил свои первые отзывы с одной-двумя звездами от устройств Xiaomi/Meizu из-за падений приложения. Эти случаи я стал обрабатывать отдельно (mFingerprintAuthenticator.startListening() возвращает false, если что-то пошло не так, и просто отключается вход по отпечатку).

На некоторых смартфонах, в основном китайских производителей, возникает еще одна проблема: у устройства с физическим наличием сканера отпечатков пальцев и версией Android’a 6.0+ стандартные методы для проверки его доступности из класса FingerprintManagerCompat (isHardwareDetected() и hasEnrolledFingerprints()) говорят, что сканер не поддерживается. Это подтверждается и проверкой через PackageManager.getSystemAvailableFeatures, в списке результатов которой нет поддержки сканера отпечатка пальцев. Найти решение этой проблемы мне пока, к сожалению, не удалось (хотя в других приложениях, например банковских, разработчикам удалось заставить работать сканер отпечатков), поэтому приходится мириться с некоторыми негативными отзывами от пользователей.

Шифрование и хранение мастер-пароля

С шифрованием/расшифровкой мастер-пароля, как уже было упомянуто выше, тоже не все в порядке. Общая логика работы следующая:

  1. При первом запуске приложения, если версия android 6.0+ и есть сканер отпечатков, создаем пару ключей для шифрования/расшифровки и прячем их в KeyStore. Если условия не соблюдены, то нет никакой необходимости хранить и шифровать мастер-пароль.
  2. Далее, при создании пользователем нового хранилища, мы шифруем введенный пароль, используя Cipher, и в зашифрованном виде храним в базе данных.
  3. Если аутентификация с помощью сканера отпечатка пальца прошла успешно, тогда достаем из KeyStor наши ключи и при помощи Cipher расшифровываем мастер-пароль, который используем для доступа к базе данных.

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

Поскольку в Android нет достаточно надежного места для хранения произвольной информации, но предоставлены инструменты для надежного хранения ключевой пары, к которой будет доступ только у нашего приложения, я формирую секретный и публичный ключи. Публичный ключ используется для зашифровки мастер-пароля, когда пользователь создает новое хранилище. Секретный (приватный) ключ используется для расшифровки этого пароля, когда пользователь входит в приложение по отпечатку пальца и, соответственно, не вводит сам пароль, который потом используется для доступа к базе данных.

Код формирования пары ключей:

@RequiresApi(api = Build.VERSION_CODES.M)
private void createKeys() throws CryptographerInitializationError {
    try {
        Calendar keyValidityStart = new GregorianCalendar();
        Calendar keyValidityEnd = new GregorianCalendar();
        keyValidityEnd.add(GregorianCalendar.YEAR, 42);

        KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance(KeyProperties.KEY_ALGORITHM_RSA, ANDROID_KEY_STORE);
        keyPairGenerator.initialize(new KeyGenParameterSpec.Builder(APPLICATION_KEY_PAIR_ALIAS,
                KeyProperties.PURPOSE_ENCRYPT | KeyProperties.PURPOSE_DECRYPT)
                .setUserAuthenticationRequired(true)
                .setCertificateSubject(new X500Principal("CN=" + APPLICATION_KEY_PAIR_ALIAS))
                .setCertificateSerialNumber(BigInteger.valueOf(1337))
                .setBlockModes(KeyProperties.BLOCK_MODE_ECB)
                .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_RSA_PKCS1)
                .setKeyValidityStart(keyValidityStart.getTime())
                .setKeyValidityEnd(keyValidityEnd.getTime())
                .build());
        keyPairGenerator.generateKeyPair();
        LoggerPassdata.i(LoggerPassdata.TAG_CRYPTOGRAPHER, "KeyPair created successfully {createKeys()}");
    } catch (Exception e) {
        LoggerPassdata.e(LoggerPassdata.TAG_CRYPTOGRAPHER, "Could not create keys {createKeys()}", e);
        e.printStackTrace();
        throw new CryptographerInitializationError(e);
    }

В качестве решения во время запуска приложения при создании Cryptographer мы получаем KeyStore и проверяем наличие ключей, а если их нет, то еще раз пробуем их создать.

Во время шифрования или расшифровки пароля тоже могут возникнуть проблемы. Например, мне пришло несколько crash report с устройств Xiaomi, на которых метод CipherOutputStream.close() выбрасывал IOException в процессе расшифровки пароля. Дело в том, что при любом исключении методы расшифровки и шифрования пароля возвращают пустую строку, а при входе по отпечатку расшифрованный пароль хешируется и сравнивается с сохраненным хешем, как и при обычном входе. При сравнении пустой строки и хеша и возникало исключение. Если же ошибка не возникла, тогда пускаем пользователя дальше, если нет — сообщаем ему о проблеме и предлагаем войти по мастер-паролю, при этом заново пересоздаем ключи и шифруем введенный пароль.

Значительно усложняет борьбу с этими проблемами отсутствие у приложения разрешения на доступ к интернету, отсюда невозможность автоматически собирать данные о сбоях и всю интересующую нас информацию о работе приложения. Выход из ситуации есть: пользователь имеет возможность сформировать отчет в приложении и отправить его по email разработчику, а если не хочется реализовывать это самому, тогда можно воспользоваться сторонними библиотеками (например, ACRA).

Теперь перейдем к тому, как непосредственно хранится введенная пользователем информация. Для этих целей я воспользовался довольно популярной и проверенной временем библиотекой SQLCipher. Она является расширением обычного SQLite и шифрует страницы базы данных перед тем, как их записать, а при чтении расшифровывает. В процессе этого используется алгоритм шифрования AES-256. Работа с данной библиотекой ведется точно так же, как и с обычным SQLite, никаких проблем при этом у меня не возникало.

Релиз

В Play Market я решил выложить сначала бета-версию приложения, надеясь, что смогу отловить и исправить основные проблемы, при этом не получив отрицательных отзывов. Но, к моему сожалению, его просто никто не скачивал. Да, не было никакой рекламы и пиара даже в социальных сетях, но я надеялся хотя бы увидеть свое приложение в поиске Play Market, и на 5-10 установок в неделю. Возможно, причина этого в тематике приложения: никто не хочет доверять важную информацию приложению от неизвестного разработчика.

Польза от функции бета-тестирования в консоли разработчика Google play в любом случае есть. Все обновления я сначала выкладываю в закрытом бета-тесте, а полномасштабное обновление даю в открытый доступ только после тестирования на своем устройстве.

Заключение

В итоге получилось довольно интересное приложение, сочетающее в себе уникальный набор функций. Далее я планирую активно развивать его, повышая стабильность, улучшая производительность и добавляя новые возможности.
Всем спасибо за внимание, делитесь своим мнением в комментариях!