Недавно мне довелось побеседовать с разработчиком Thunderbird о проектировании API. В ходе этой беседы я поделился соображениями о RNP, новой реализации OpenPGP, которую Thunderbird недавно стал использовать вместо GnuPG.
Собеседник скептически отнесся к моему тезису о том, что API RNP нуждается в улучшении, и спросил, «разве это не субъективно – какие API лучше, а какие хуже?». Согласен, у нас нет хороших метрик для оценки API. Но не соглашусь, что мы в принципе не в силах судить об API.
На самом деле, подозреваю, что большинство опытных программистов узнают плохой API, если увидят его. Думаю, далее в этой статье получится разработать хорошую эвристику, которую я попытаюсь выстроить на моем собственном опыте работы с (и над) GnuPG, Sequoia и RNP. Затем я рассмотрю API RNP. К сожалению, этот API не только можно запросто использовать неправильно – он к тому же обманчив, поэтому пока его не следует использовать в контекстах, где принципиальная роль отводится соблюдению безопасности. Но целевая аудитория Thunderbird – это люди, известные своей уязвимостью, в частности, журналисты, активисты, юристы и их партнеры, отвечающие за коммуникацию; все эти люди нуждаются в защите. На мой взгляд, это означает, что в Thunderbird должны лишний раз подумать, стоит ли использовать RNP.
Примечание: также предлагаю ознакомиться с этим электронным письмом: Let’s Use GPL Libraries in Thunderbird!, которое я отправил в постовую рассылку по планированию развития Thunderbird.
Каковы черты плохого API?
Прежде, чем мы вместе с Юстусом и Каем приступили к проекту Sequoia, мы все втроем работали над GnuPG. Мы не только сами копались в gpg, но и беседовали, и сотрудничали со многими последующими пользователями gpg. Люди смогли сказать много хорошего по поводу GnuPG.
Что касается критики gpg, наиболее значительными нам показались два вида замечаний по поводу API. Первое сводится к следующему: API gpg слишком догматичен. Например, в gpg применяется подход, основанный на использовании личной базы ключей (keyring). Таким образом, просмотреть или использовать сертификат OpenPGP можно лишь в том случае, если он импортирован в личную базу ключей. Но некоторые разработчики желают сначала просмотреть сертификат, и лишь потом импортировать его. Например, при поиске сертификата на сервере ключей по его отпечатку, можно проверить и убедиться, что возвращенный сертификат – действительно тот, что нужен, поскольку его URL является самоаутентифицируемым. Это можно сделать при помощи gpg, но только обходным путем, огибая принципы той модели программирования, которая в него заложена. Базовая идея такова: создать временный каталог, добавить в него конфигурационный файл, приказать gpg использовать альтернативный каталог, импортировать туда сертификат, проверить сертификат, после чего очистить временный каталог. Это официальная рекомендация, добавленная Юстусом на основе наших бесед с последующими пользователями gpg. Да, этот метод работает. Но для него требуется писать код, специфичный для операционной системы, этот код медленный, и в нем часто заводятся ошибки.
Другой класс замечаний, с которыми мы сталкивались неоднократно, заключается в том, что для работы с gpg требуется знать массу неочевидных вещей – чтобы не злоупотреблять этим механизмом. Или, выражаясь иначе, нужно проявлять крайнюю осмотрительность при использовании API gpg, чтобы ненароком не привнести в код уязвимость.
Чтобы лучше понять второй повод для беспокойства, рассмотрим уязвимости EFAIL. Основная проблема, связанная с API дешифрования gpg: при дешифровании сообщения gpg выдает обычный текст, даже если ввод был поврежден. gpg в таком случае действительно возвращает ошибку, но некоторые программы все равно выводят обычный текст в поврежденном виде. Так как, почему нет? Определенно, лучше показать хотя бы часть сообщения, чем не показать ничего, верно? Так вот, уязвимости EFAIL демонстрируют, как злоумышленник может этим воспользоваться, чтобы внедрить веб-баг в зашифрованное сообщение. Когда пользователь просматривает это сообщение, веб-баг просачивается из сообщения. Уф.
Итак, на чьей совести этот баг? Разработчики GnuPG настаивали, что проблема – на уровне приложений, в том, что они используют gpg неправильно:
Рекомендуется, чтобы в почтовых пользовательских агентах учитывался код состояния DECRYPTION_FAILED, и не отображались данные, либо, как минимум, подбирался уместный способ для отображения потенциально поврежденной почты, не создавая оракул и информируя пользователя о том, что почта не внушает доверия.
gpg просигнализировала об ошибке; приложения не соблюдают контракт API. Должен согласиться с разработчиками GnuPG и добавить: интерфейс gpg был (и остается) бомбой замедленного действия, поскольку не подсказывает пользователю, как действовать правильно. Напротив, легкое и, казалось бы, полезное действие является неверным. И API такого типа, к сожалению, обычны в GnuPG.
Из чего слагается хороший API?
Осознание двух этих вещей — что API gpg слишком догматичен, и что им сложно пользоваться как следует — сформировало мои планы. Когда мы приступили к проекту Sequoia, мы сошлись на том, что подобных ошибок хотим избежать. Основываясь на наблюдениях, мы ввели в практику два теста, которыми продолжаем пользоваться в качестве ориентиров при разработке API Sequoia. Во-первых, дополнительно к любому высокоуровневому API должен существовать и низкоуровневый, который не догматичен – в том смысле, что не мешает пользователю делать что-либо, что не запрещено. В то же время, API должен наводить пользователя на верные (жестко прописанные) вещи, делая так, чтобы правильные действия были просты в исполнении и наиболее очевидны при выборе действия.
Для воплощения двух этих слегка конфликтующих целей – сделать возможным всё, но предотвратить ошибки, мы особенно активно опирались на два средства: типы и примеры. Благодаря типам, становится сложно использовать объект непредусмотренным образом, так как контракт API формализуется во время компиляции и даже навязывает конкретные преобразования. Примеры же — фрагменты кода — будут копироваться. Поэтому, хорошие примеры не только приучат пользователей правильно обращаться с функцией, но и сильно повлияют на то, как именно они будут ее использовать.
Типы
Покажу на примере, как мы используем типы в Sequoia, и как они помогают нам сделать хороший API. Чтобы пример был понятнее, полезно будет напомнить некоторый контекст, касающийся OpenPGP.
Простой сертификат OpenPGP
В OpenPGP существует несколько фундаментальных типов данных, а именно: сертификаты, компоненты (например, ключи и пользовательские ID), а также подписи привязки. Корень сертификата – это первичный ключ, полностью определяющий отпечаток сертификата (fingerprint = Hash(primary key)). В состав сертификата обычно входят такие компоненты как подключи и пользовательские ID. OpenPGP привязывает компонент к сертификату при помощи так называемой подписи привязки. Когда мы используем в качестве отпечатка обычный хеш первичного ключа и используем подписи для привязки компонентов к первичному ключу, создаются условия, чтобы впоследствии можно было добавить и дополнительные компоненты. В состав подписей привязки также входят свойства. Поэтому есть возможность изменить компонент, например, продлить срок действия подключа. Вследствие этого с конкретным компонентом может быть ассоциировано несколько действительных подписей. Подписи привязки являются не только фундаментальной, но и неотъемлемой частью механизма безопасности OpenPGP.
Поскольку может существовать множество действительных подписей привязки, требуется способ выбирать из них нужную. В качестве первого приближения предположим, что нужная нам подпись – самая недавняя, неистекшая, неотозванная действительная подпись, создание которой не отложено на будущее. Но что такое действительная подпись? В Sequoia подпись должна не только пройти математическую проверку, но и согласовываться с политикой. Например, в силу противодействия скомпрометированным коллизиям, мы допускаем SHA-1 только в очень небольшом количестве ситуаций. (Пол Шауб, работающий над PGPainless, недавно подробно написал об этих сложностях.) Вынуждая пользователя API держать в уме все эти соображения, мы создаем почву для уязвимостей. В Sequoia легкий способ получить время истечения – это безопасный способ. Рассмотрим следующий код, который работает как надо:
let p = &StandardPolicy::new();
let cert = Cert::from_str(CERT)?;
for k in cert.with_policy(p, None)?.keys().subkeys() {
println!("Key {}: expiry: {}",
k.fingerprint(),
if let Some(t) = k.key_expiration_time() {
DateTime::<Utc>::from(t).to_rfc3339()
} else {
"never".into()
});
}
cert
– это сертификат. Начинаем с применения политики к нему. (Политики определяются пользователем, но, как правило, StandardPolicy не только достаточна, но и наиболее уместна). Фактически здесь создается представление сертификата, в котором видны только компоненты с действительной подписью привязки. Важно, что она также изменяет и предоставляет ряд новых методов. Метод keys, к примеру, изменен так, что возвращает ValidKeyAmalgamation вместо KeyAmalgamation. (Это слияние, так как результат включает не только Key, но и все подписи, связанные с ним; некоторые считают, что этот процесс было бы удачнее назвать Катамари. ?\_(?)_/?) У ValidKeyAmalgamation есть действительная подпись привязки, согласно вышеприведенным критериям. А также предоставляет такие методы как key_expiration_time, что имеет смысл только с действительным ключом! Также отметим: возвращаемый тип, используемый с key_expiration_time, эргономичен. Вместо того, чтобы возвращать необработанное значение, key_expiration_time возвращает SystemTime, безопасный и удобный в работе.В соответствии с нашим первым принципом «позволить использовать все», разработчик по-прежнему сохраняет доступ к единичным подписям и исследует субпакеты, чтобы узнать из иной привязки подписи, когда истекает срок действия ключа. Но, по сравнению с тем, как в API Sequoia положено правильно узнавать срок истечения действия ключа, любой иной подход противоречил бы API. По нашему мнению, это хороший API.
Примеры
Релиз 1.0 библиотеки Sequoia состоялся в декабре 2020 года. За девять месяцев до того мы вышли на ситуацию полной работоспособности (feature complete) и были готовы к релизу. Но выжидали. Следующие девять месяцев ушли у нас на добавление документации и примеров к публичному API. Посмотрите документацию к структуре данных Cert в качестве примера, посмотрите, что у нас получилось. Как указано в нашем посте, нам не удалось предоставить примеры для всех функций до одной, но мы успели довольно много. В качестве бонуса к написанию примеров мы также успели найти несколько шероховатостей, которые при этом отполировали.
После релиза нам удалось пообщаться со многими разработчиками, включившими Sequoia в свой код. Красной нитью через их отзывы проходили признания в том, насколько полезны оказались и документация, и примеры. Можем подтвердить: хотя это и наш код, мы заглядываем в документацию практически ежедневно и копируем наши собственные примеры. Так проще. Поскольку в примерах показано, как правильно использовать ту или иную функцию, зачем переделывать работу с нуля?
API RNP
RNP – это свежая реализация OpenPGP, разработанная преимущественно Ribose. Примерно два года назад в Thunderbird решили интегрировать Enigmail в Thunderbird и одновременно с этим заменить GnuPG на RNP. Тот факт, что в Thunderbird выбрали RNP – не только лестен для RNP; он также означает, что RNP стал, пожалуй, самой востребованной реализацией OpenPGP для шифрования почты.
Критику легко воспринять как негатив. Я хочу совершенно однозначно высказаться: думаю, что работа, которую делают в Ribose, хороша и важна, я благодарен им за то, что они вкладывают время и силы в новую реализацию OpenPGP. В экосистеме OpenPGP отчаянно требуется добавить разнообразия. Но это не оправдание за выпуск незрелого продукта для использования в контексте, где безопасность критически важна.
Инфраструктура с критически важными требованиями к безопасности
К сожалению, RNP пока не дошла до состояния в котором, на мой взгляд, ее можно безопасно развертывать. Enigmail пользовались не только люди, озабоченные приватностью своих данных, но и журналисты, активисты и адвокаты, которым важна собственная безопасность и безопасность их собеседников. В интервью, данном в 2017 году, Бенджамин Исмаил, глава Азиатско-Тихоокеанского отделения организации Репортеры без границ, сказал:
В основном мы используем GPG для свободной коммуникации с нашими источниками. Информация, которую они сообщают нам о правах человека и нарушениях этих прав – небезопасна для них, поэтому необходимо защищать неприкосновенность наших разговоров.
Интервью с Бенджамином Исмаилом из организации Репортеры без границ
Принципиально важно, чтобы Thunderbird и далее обеспечивала этих пользователей максимально безопасными возможностями работы, даже в такой переходный период.
RNP и подписи привязки подключа
Говоря о том, как мы используем типы в Sequoia для того, чтобы усложнить неправильное использование API, я показал, как узнать срок истечения действия ключа, написав всего несколько строк кода. Хотел начать с примера, демонстрирующего человеку, неискушенному в OpenPGP или RNP, как тот же самый функционал можно реализовать при помощи RNP. Следующий код перебирает подключи сертификата (key) и выводит на экран срок истечения действия каждого подключа. Напоминаю: время истечения действия хранится в подписи привязки подключа, а значение 0 свидетельствует, что срок действия ключа не истечет никогда.
int i;
for (i = 0; i < sk_count; i ++) {
rnp_key_handle_t sk;
err = rnp_key_get_subkey_at(key, i, &sk);
if (err) {
printf("rnp_key_get_subkey_at(%d): %x\n", i, err);
return 1;
}
uint32_t expiration_time;
err = rnp_key_get_expiration(sk, &expiration_time);
if (err) {
printf("#%d (%s). rnp_key_get_expiration: %x\n",
i + 1, desc[i], err);
} else {
printf("#%d (%s) expires %"PRIu32" seconds after key's creation time.\n",
i + 1, desc[i],
expiration_time);
}
}
Я проверил этот код на сертификате с пятью подключами. Первый подключ имеет действительную подпись привязки и не истекает; второй имеет действительную подпись привязки и в будущем истекает; у третьего действительная подпись привязки, но срок его действия уже истек; у четвертого недействительная подпись привязки, согласно которой срок действия подключа истекает в будущем; у пятого подписи привязки нет вообще. Вот вывод:
#1 (doesn't expire) expires 0 seconds after key's creation time.
#2 (expires) expires 94670781 seconds after key's creation time.
#3 (expired) expires 86400 seconds after key's creation time.
#4 (invalid sig) expires 0 seconds after key's creation time.
#5 (no sig) expires 0 seconds after key's creation time.
Первым делом отмечаем, что вызов rnp_key_get_expiration оканчивается успешно, независимо от того, есть ли у подключа действительная подпись привязки, либо недействительная подпись привязки, либо вообще нет подписи привязки. Если почитать документацию, то это поведение кажется немного удивительным. Там сказано:
Получить время истечения срока действия ключа в секундах.
Обратите внимание: 0 означает, что ключ не истечет никогда.
Поскольку время истечения срока действия ключа сохранено в подписи привязки, я, эксперт по OpenPGP, понимаю это так: вызов к rnp_key_get_expiration увенчается успехом лишь в том случае, если у подключа есть действительная подпись привязки. На самом же деле оказывается, что, если действительная подпись привязки отсутствует, то функция просто принимает по умолчанию значение 0, что, учитывая вышеприведенное замечание, пользователь API ожидаемо интерпретирует так: данный ключ действует бессрочно.
Чтобы улучшить этот код, сначала необходимо проверить, есть ли у ключа действительная подпись привязки. Некоторые функции, делающие именно это, недавно были добавлены в RNP для решения проблемы CVE-2021-23991. В частности, разработчики RNP добавили функцию rnp_key_is_valid, чтобы возвращать информацию о том, действителен ли ключ. Это дополнение улучшает ситуацию, но требует от разработчика явно выбирать, должны ли проводиться эти критичные для безопасности проверки (а не явно отказываться от уже заданных проверок – как было бы в случае работы с Sequoia). Поскольку проверки безопасности не относятся к выполнению полезной работы, о них легко забыть: код работает, даже если проверка безопасности проведена не была. А поскольку чтобы правильно выбрать, что проверять, нужны экспертные знания, о проверках забывают.
Следующий код предусматривает проверку безопасности и пропускает любые ключи, которые rnp_key_is_valid сочтет недействительными:
int i;
for (i = 0; i < sk_count; i ++) {
rnp_key_handle_t sk;
err = rnp_key_get_subkey_at(key, i, &sk);
if (err) {
printf("rnp_key_get_subkey_at(%d): %x\n", i, err);
return 1;
}
bool is_valid = false;
err = rnp_key_is_valid(sk, &is_valid);
if (err) {
printf("rnp_key_is_valid: %x\n", err);
return 1;
}
if (! is_valid) {
printf("#%d (%s) is invalid, skipping.\n",
i + 1, desc[i]);
continue;
}
uint32_t expiration_time;
err = rnp_key_get_expiration(sk, &expiration_time);
if (err) {
printf("#%d (%s). rnp_key_get_expiration: %x\n",
i + 1, desc[i], err);
} else {
printf("#%d (%s) expires %"PRIu32" seconds after key's creation time.\n",
i + 1, desc[i],
expiration_time);
}
}
Вывод:
#1 (doesn't expire) expires 0 seconds after key's creation time.
#2 (expires) expires 94670781 seconds after key's creation time.
#3 (expired) is invalid, skipping.
#4 (invalid sig) is invalid, skipping.
#5 (no sig) is invalid, skipping.
Этот код правильно пропускает два ключа, не имеющих действительной подписи привязки, но он также пропускает и истекший ключ – пожалуй, не этого мы хотели, пусть документация нас и предупреждает, что эта функция «проверяет … сроки истечения».
Хотя бывает и так, что мы не хотим использовать тот ключ или сертификат, срок действия которого истек, иногда мы к ним прибегаем. Например, если пользователь забудет продлить срок действия ключа, то у него должна быть возможность увидеть, что ключ истек, и тогда проверить сертификат, а также продлить в таком случае срок действия ключа. Хотя
gpg --list-keys
не показывает истекшие ключи, при редактировании сертификата все-таки видны истекшие подключи, так, чтобы пользователь мог продлить срок их действия:$ gpg --edit-key 93D3A2B8DF67CE4B674999B807A5D8589F2492F9
Secret key is available.
sec ed25519/07A5D8589F2492F9
created: 2021-04-26 expires: 2024-04-26 usage: C
trust: unknown validity: unknown
ssb ed25519/1E2F512A0FE99515
created: 2021-04-27 expires: never usage: S
ssb cv25519/8CDDC2BC5EEB61A3
created: 2021-04-26 expires: 2024-04-26 usage: E
ssb ed25519/142D550E6E6DF02E
created: 2021-04-26 expired: 2021-04-27 usage: S
[ unknown] (1). Alice <alice@example.org>
Есть и другие ситуации, в которых истекший ключ не должен считаться недействительным. Предположим, например, что Алиса посылает Бобу подписанное сообщение: «я заплачу тебе 100 евро за год,» а срок действия ключа подписи истекает через полгода. Когда год закончится, будет ли Алиса должна Бобу, если исходить из этой подписи? По-моему, да. Подпись была действительна, когда ставилась. Тот факт, что срок действия ключа уже истек, не имеет значения. Разумеется, когда ключ истек, подписи, скрепленные им после момента его истечения, должны считаться недействительными. Аналогично, сообщение не следует шифровать истекшим ключом.
Короче говоря, то, должен ли ключ считаться действительным, сильно зависит от контекста. rnp_key_is_valid лучше, чем ничего, но, несмотря на название, эта функция предусматривает достаточно нюансов при определении того, действителен ли ключ.
В рамках того же коммита была добавлена вторая функция,
rnp_key_valid_till
. Эта функция возвращает «метку времени, до наступления которого ключ может считаться действительным… Если ключ никогда не был действителен, то в качестве значения возвращается ноль.» При помощи этой функции можно определить, был ли ключ действителен когда-либо, для этого нужно проверить, возвращает ли эта функция ненулевое значение:int i;
for (i = 0; i < sk_count; i ++) {
rnp_key_handle_t sk;
err = rnp_key_get_subkey_at(key, i, &sk);
if (err) {
printf("rnp_key_get_subkey_at(%d): %x\n", i, err);
return 1;
}
uint32_t valid_till;
err = rnp_key_valid_till(sk, &valid_till);
if (err) {
printf("rnp_key_valid_till: %x\n", err);
return 1;
}
printf("#%d (%s) valid till %"PRIu32" seconds after epoch; ",
i + 1, desc[i], valid_till);
if (valid_till == 0) {
printf("invalid, skipping.\n");
continue;
}
uint32_t expiration_time;
err = rnp_key_get_expiration(sk, &expiration_time);
if (err) {
printf("rnp_key_get_expiration: %x\n", err);
} else {
printf("expires %"PRIu32" seconds after key's creation time.\n",
expiration_time);
}
}
Результаты:
#1 (doesn't expire) valid till 1714111110 seconds after epoch; expires 0 seconds after key's creation time.
#2 (expires) valid till 1714111110 seconds after epoch; expires 94670781 seconds after key's creation time.
#3 (expired) valid till 1619527593 seconds after epoch; expires 86400 seconds after key's creation time.
#4 (invalid sig) valid till 0 seconds after epoch; invalid, skipping.
#5 (no sig) valid till 0 seconds after epoch; invalid, skipping.
Теперь мы получили те результаты, которых добивались! Мы выводим на экран правильное время истечения для первых трех подключей, а также указываем, что последние два подключа недействительны.
Но давайте подробнее рассмотрим
rnp_key_valid_till
. Во-первых, в OpenPGP время истечения ключа хранится как беззнаковый 32-разрядный отступ от времени создания ключа, также в беззнаковом 32-разрядном формате. Следовательно, функция должна была бы использовать более широкий тип или, как минимум, проверять код на переполнение. (Я сообщил об этой проблеме, и ее уже исправили.)Но, даже если игнорировать этот косяк, функция все равно странная. В OpenPGP ключ может быть действителен в течение нескольких периодов времени. Допустим, срок действия ключа истекает 1 июля, а пользователь продлевает его только с 10 июля. В период с 1 по 10 июля ключ был недействителен, а подписи, сгенерированные в это время, также должны считаться недействительными. Итак, что же должна возвращать рассматриваемая функция для такого ключа? Гораздо важнее, как пользователь такого API должен интерпретировать результат? Уместно ли вообще использовать такой API? (Да, я спрашивал.)
В Sequoia мы пошли другим путем. Вместо того, чтобы возвращать информацию, что ключ действителен, мы переворачиваем ситуацию; пользователь API может спросить: действителен ли этот ключ в момент t. По нашему опыту, это все, что на самом деле требовалось во всех известных нам случаях.
Не подумайте, что я специально придираюсь именно к этой проблеме с API RNP. Это просто сложность, о которой я недавно размышлял. Когда мы заново реализовали API RNP, чтобы создать альтернативный бэкенд OpenPGP для Thunderbird, мы столкнулись со многими подобными проблемами.
Заключение
Ошибки, допущенные разработчиками RNP, понятны и простительны. OpenPGP сложен, как и многие другие протоколы. Но его можно существенно упростить, если мы стремимся сохранить его гибкую и надежную ИОК, а не просто иметь инструмент для шифрования файлов.
Тем не менее, API RNP опасен. А Thunderbird используется в контекстах с критическими требованиями к безопасности. В интервью от 2017 года Михал ‘Рысьек’ Возняк из Центра по исследованию коррупции и организованной преступности (OCCRP) четко сообщил, что на кону чьи-то жизни:
Я действительно категорически уверен, что, если бы мы не использовали GnuPG все это время, то многие наши информаторы и журналисты оказались бы в опасности или за решеткой…
Интервью с Михалом ‘Рысьеком’ Возняком из Центра по исследованию коррупции и организованной преступности
Как это отразится на Thunderbird? Вижу три варианта. Во-первых, Thunderbird мог бы переключиться обратно на Enigmail. Можно подумать, что портирование Enigmail на Thunderbird 78 далось бы сложно, но я слышал от многих разработчиков Thunderbird, что это технически осуществимо вполне подъемными усилиями. Но одна из причин, по которым Thunderbird предпочла уйти от Enigmail – огромное время, которое разработчикам Enigmail приходилось тратить, чтобы помочь пользователям правильно установить и сконфигурировать GnuPG. Поэтому такой путь неидеален.
Во-вторых, Thunderbird могла бы переключиться на иную реализацию OpenPGP. В наше время их целая куча на выбор. Лично я считаю, что Thunderbird следовало бы переключиться на Sequoia. Конечно же, я разработчик Sequoia, поэтому необъективен. Но дело здесь не в деньгах: мне платит фонд, а на свободном рынке мне предложили бы, пожалуй, вдвое больше, чем я зарабатываю сейчас. Я работаю ради того, чтобы защитить пользователей. Но, даже кроме API Sequoia и преимуществ реализации, Thunderbird в данном случае выигрывает и еще в одном отношении: мы уже заставили эту реализацию работать. Несколько недель назад мы выпустили Octopus, альтернативный бекенд OpenPGP для Thunderbird. У него не только функциональный паритет с RNP, но и есть ряд ранее недостававших фич, например, интеграция с gpg, а также залатаны некоторые бреши в безопасности и выполнено несколько нефункциональных требований.
В-третьих, Thunderbird мог бы вообще отказаться от использования OpenPGP. Такое решение меня не устраивает. Но несколько раз мне доводилось беспокоиться о безопасности наиболее уязвимых пользователей Thunderbird, и я считаю, что не предоставлять никакой поддержки OpenPGP вообще – это решение, возможно, даже более безопасное, чем статус-кво.
VPS от Маклауд идеально подходят для разработки API.
Зарегистрируйтесь по ссылке выше или кликнув на баннер и получите 10% скидку на первый месяц аренды сервера любой конфигурации!
Noospheratu
К Thunderbird есть много вопросов…
Например, когда Undo\Redo в редакторе писем начнёт нормально работать?
Перенабирать всё письмо целиком из-за нажатого Ctrl-Z и невозможности Ctrl-Y — это перебор.