Этот пост про потерю данных, а не про конфиденциальность.

tl;dr

Я пользовался менеджером паролей SafeInCloud и перешел на Bitwarden.

Импорт теряет пароли и обрезает длинные урлы. Тестов почти нет. Баги есть.

Почему перехожу

Есть пост на хабре “Извлечение мастер-пароля из заблокированного менеджера паролей SafeInCloud”. Автор SafeInCloud воспринял критику негативно. Отрицать проблемы - это плохой выход. Хороший выход - написать официальный ответ на пост. Нужно признать проблему и опубликовать способ решения. Автор этого не сделал.

Следующая причина перехода - opensource. У bitwarden есть проблемы и их видно в коде. За 3 года использования SafeInCloud я не замечал багов. Но это не значит что багов нет - просто они не доступны для аудита

Аудит безопасности (не мой)

В октябре 2018 года компания Cure53 провела аудит кода Bitwarden. Автор Bitwarden не скрывает проблемы и публикует pdf отчет на своем cdn. Честность - это хорошо. Отчет подробный, даже куски кода с подсветкой проблем указаны. Его можно читать не только экспертам по безопасности, он написан для людей. Я не нашел причин не использовать bitwarden.

История моего перехода на bitwarden

Bitwarden умеет импортировать базу SafeInCloud в формате xml. Я экспортировал базу через десктопное приложение SafeInCloud и импортировал в консольной версии Bitwarden. Bitwarden немного подумал и без ошибок завершил процесс. Я проверил несколько из своих записей и успокоился. Прошло несколько дней и я решил авторизоваться в гитхабе. Нашел запись github, но в ней не было пароля. Проверил другие записи и там тоже были утеряны пароли. Решил изучить исходники. Я ни когда не писал на typescript, но на поиск ошибки ушло менее 5ти минут. Вот строки с ошибкой:

src/importers/safeInCloudXmlImporter.ts#L72

Array.from(this.querySelectorAllDirectChild(cardEl, 'field')).forEach((fieldEl) => {
    const text = fieldEl.textContent;
    if (this.isNullOrWhitespace(text)) {
        return;
    }
    const name = fieldEl.getAttribute('name');
    const fieldType = this.getValueOrDefault(fieldEl.getAttribute('type'), '').toLowerCase();
    if (fieldType === 'login') {
        cipher.login.username = text;
    } else if (fieldType === 'password') {
        cipher.login.password = text;
    }
// удалил код, который не относится к ошибке
    else {
       this.processKvp(cipher, name, text);
    }
});

В цикле идём по всем полям записи из SafeInCloud. Если тип записи password - происходит присвоение переменной cipher.login.password. Если запись имеет 2 поля с типом password, сначала запишется первое, потом второе его сотрёт. Вот именно это и произошло. У меня было поле Password(тип password) и поле Token(тип password). Password потерялся, на его место записался token. Такое произошло для каждой записи, где кроме пароля есть другие поля.

Я не обнаружил ошибку сразу и возникли проблемы. Я мог бы написать свой конвертер и перенести пароли без проблем. Но за несколько дней я сменил в “битой” базе много паролей к разным записям. Нужно было как то синхронизировать “битый” импорт и старую базу SafeInCloud.

Для сравнения разъехавшихся баз я решил использовать diff в intellij idea. Написал скрипт для приведения разных баз к одинаковому виду. Строка начинается с названия записи. Дальше идут отсортированные пароли через разделитель. В конце строки печатается notes. В таком виде хорошо видно какие записи обновлялись. Результат этого сравнения я обрабатываю вручную.

Я написал свой скрипт конвертации базы паролей с учетом всех недостатков safeInCloudXmlImporter из bitwarden/jslib.

Мой скрипт импорта

https://github.com/d10xa/safeincloudxml-to-bitwardenjson

У меня нет организации и перенос папок в коллекции я не делал (и не разбирался что это значит)

if (this.organization) {
    this.moveFoldersToCollections(result);
}

Файлы, картинки и иконки я не переносил (Но, так как я сохраняю id записей, я смогу их дописать при необходимости)

Идентификаторы директорий я беру из предыдущего бэкапа, но к сожалению bitwarden их игнорирует и создает новые директории.

Если есть поле больше 500 символов - скрипт не будет его обрезать а упадёт с ошибкой.

Валидация моего импорта

Для проверки моего скрипта я написал тест.

Проверяю вот что:

  • password
  • username
  • uri
  • Два поля с одинаковым типом
  • Два поля с одинаковым именем
  • Заметки
  • Директории

Такого простого теста не достаточно. Нужно валидировать результат внимательным чтением.

Конвертация, которой можно доверять без дополнительных проверок выглядит так:

toSafeInCloud(toBitwarden(safeInCloudDatabase)) == safeInCloudDatabase

Такое преобразование не работает. Есть информация, которая теряется при переходах (не куда записать значения в БД bitwarden):

  • Имя поля. В SafeInCloud поле может называться password или code, а в Bitwarden я его сохраняю по пути cipher.login.password . Поля login или email пишу в cipher.login.username
  • Шаблоны
  • Примеры
  • Картинки
  • Файлы
  • websiteIcon
  • customIcon
  • labelId (теряются все кроме одного)
  • score
  • hash
  • symbol
  • color
  • star
  • time_stamp

Я добавил в каждую запись поле safeincloud_card_id на случай, если мне понадобится что то обновить в записях или сделать обратный переход на SafeInCloud.

Отсутствие тестов

Цитата из лайфхакер ру

Если вы переезжаете на Bitwarden из другого парольного менеджера, вам не придётся переносить все данные и комбинации вручную. Программа поддерживает функции импорта и экспорта и может принимать пароли из огромного количества других приложений и браузеров — LastPass, 1Password, Blur, Chrome, Dashlane, Enpass, Firefox, KeePass, Opera, PassKeep, RoboForm, Vivaldi и Zoho.

Я насчитал 46 скриптов импорта из разных менеджеров паролей. Только для двух написаны unit тесты. Для 1password написан тест, где на вход даётся заполненная база и проверяются пароли после импорта таким образом expect(ph.password).toEqual('oldpass5');

Второй счастливчик это keepass. На вход даётся заполненная база, а тест проверяет результат на ≠ null:

describe('KeePass2 Xml Importer', () => {
    it('should parse XML data', async () => {
        const importer = new Importer();
        const result = importer.parse(TestData);
        expect(result != null).toBe(true);
    });
});

Все остальные скрипты не тестируются.

Я посмотрел на другие репозитории bitwarden. Большинство тестов автоматически генерируемые. Клиентские приложения не имеют тестов: плагин для браузера, веб версия, консольное, мобильное и десктоп приложение.

server. бэкэнд на C#

  • 28 тестовых классов. 25 из них такие:
// Remove this test when we add actual tests. It only proves that
// we've properly constructed the system under test.
[Fact(Skip = "Needs additional work")]
public void ServiceExists()
{
    Assert.NotNull(_sut);
}
  • Тесты есть в CollectionServiceTests.cs 5 тестов, DeviceServiceTests.cs 1 тест, OrganizationServiceTests.cs 2 теста
  • Всё взаимодействие с MSSQL реализовано на хранимых процедурах

jslib. Общий js код

  • импортируется в большинстве других проектов
  • Тестов мало (7 файлов), но сильно больше чем в остальных репозиториях
  • весь код импорта из других менеджеров паролей тут

browser. Расширение для браузеров

  • Тестов нет
  • Есть зависимость на jslib.

desktop. приложение на electron+angular

  • Тестов нет
  • Есть зависимость на jslib.

mobile. Android+iOS на Xamarin

  • Весь код на C#
  • Долго открывается на старом телефоне
  • Тестов нет

web. Веб версия менеджера паролей vault.bitwarden.com

  • Тестов нет
  • Есть зависимость на jslib.

cli. Консольная утилита bw

  • Тестов нет
  • Есть зависимость на jslib.

Остальные репозитории мне не интересны; website, help, docs, directory-connector, brand

Я отключил автообновление bitwarden через google play. С таким подходом к тестированию можно в любой момент остаться без паролей под рукой.

Другие проблемы

Cвежий коммит на момент написания статьи 65e78ec1c8de13f3d6eac4a6862e532f698149b.

Метод fixUri отрезает строку, если она длиньше 1000 символов. Без предупреждений.

if (uri.length > 1000) {
    return uri.substring(0, 1000);
}

Метод nameFromUrl удаляет www из урлов. Если я указываю www, возможно я на самом деле хочу сохранить такой url а не сокращенный.

return hostname.startsWith('www.') ? hostname.replace('www.', '') : hostname;

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

cipher.notes = cipher.notes.trim();

Текстовые поля длиньше 200 символов запишутся в поле notes через разрыв строки.

issue#87 Максимальная длинна пароля 1000 символов. Но пароль из 688 символов не сохранится. 1000 символов это длинна зашифрованной строки а не пароля.

Лимит на 10к для заметок. Это не проблема, но при переходе на bitwarden стоит это учесть.

Поле one-time-password из SafeInCloud это не то же самое, что totp в bitwarden. Стандартный импорт переносит строку как есть и потом не понимает что с этим делать.

Без платной подписки можно сканировать totp, но воспользоваться нельзя. Это не очевидно и ни где не указано. issue#450

В SafeInCloud есть атрибут <card deleted="true"/>. Bitwarden import про него не знает и все удалённые записи появятся в списке живых.

У меня есть записи, в которых указано что это шаблон. Такое может легко произойти если в SafeInCloud при добавлении записи промахнуться кнопкой. Такие записи теряются при импорте. Я так потерял 4 записи из 300

Bitwarden при экспорте присваивает директориям uuid. Но при импорте игнорирует их. И если сделать импорт дважды, он создаст дубликаты папок. Удаление папок доступно в веб версии и на одну папку нужно 4 клика. У меня ~ 20 папок. Items удаляются через веб интерфейс пачками по 500 штук. В целях дебага моего собственного импорта я удалял папки много раз. Написал скрипт (очень медленно работает, не сильно быстрее ручного удаления).

bw_folder_ids=$(bw list folders | jq '.[] | .id | select(.!=null)' -r)
for i in $bw_folder_ids; do
    bw delete folder $i;
done;

UUID для директорий генерируются такие, что последние 12 символов всегда одинаковые. (Это не проблема, а просто наблюдение)

Если у записи в SafeInCloud больше одного лэйбла - остальные теряются.

Bitwarden теряет файлы, картинки и кастомные иконки.

Safeincloud учит не забывать пароль. Раз в неделю нужно вводить полный пароль вместо сканирования пальца. В мобильном Bitwarden такой возможности нет.

Баги в android приложении bitwarden

Баги наблюдал в версии 2.2.7 (2130)

  • Если сразу после старта приложения нажать поиск и что нибудь поискать - приложение закрывается.
  • Если быстро ввести строку запроса и нажать поиск, отобразится “There are no items to list.” При повторном нажатии покажется результат.
  • Сразу после включения интерфейс не реагирует на нажатия. Помогает выгрузка из памяти. Закономерность воспроизведения бага не обнаружил. Случается редко.
  • После поиска открыть любую запись на просмотр, нажать назад 2 раза. Отобразится общий список, но в нем будет неправильное количество записей

Я не тестировщик. У меня не бета версия. Я зашел на гугл плэй и перепроверил себя. Увидел предложение “Join the beta. Try new features before they’re officially released…”. Стабильная версия работает не очень стабильно. Ставить бету - страшно.

Не смотря на все эти проблемы - пользоваться можно.

Другие менеджеры паролей

Мой первый менеджер паролей - keepass. Я перешел на SafeInCloud из-за бага. Приложения для разных платформ работают по-разному. У меня некоторые записи не отображались в мобильном приложении. Я сначала подумал что они удалились и начал восстанавливать бэкапы. Потом, заметил что в веб версии эти записи существуют. Еще был неприятный баг с веб версией. Приходилось периодически чистить хранилище в браузере.

Кроме bitwarden я рассматривал другие варианты. Одним из самых интересных оказался https://www.passwordstore.org. Я его не выбрал только потому, что он не поддерживает сканер отпечатка. Android-Password-Store использует для работы с gpg ключами стороннее приложение OpenKeychain. Issue#1642 в статусе help_wanted создано в конце 2015 года и я сомневаюсь что это будет скоро реализовано.

Интересная особенность это хранение паролей в git. Имя файла в гите - имя записи(расширение файла gpg). Внутри записи есть только Password и extra. Другие поля добавить нельзя. Файл представляет собой одно PGP сообщение. При считывании пароля дешифруется только один пароль а не вся БД.

Password-Store имеет все шансы стать моим следующим менеджером паролей.

Выводы

Для упрощения перехода между менеджерами паролей не стоит пользоваться дополнительными фичами: хранение файлов, кастомные поля, totp.

После перехода стоит внимательно просмотреть на результат переноса паролей и другой информации.

Не удаляйте бэкапы от старого менеджера паролей после перехода.

Проверяйте свои бэкапы.