Увеличение безопасности файлов закрытых ключей SSH

Увеличение безопасности файлов закрытых ключей SSH

Задумывались когда-нибудь, как работают файлы ~/.ssh и насколько они защищены?

Автор: Martin Kleppmann

Как вероятно и вы, я пользуюсь SSH каждый день – при любом вызове git fetch и git push, каждом размещении и конечно при каждой процедуре авторизации на сервере. И недавно я понял, что для меня SSH – нечто неизвестное, но, тем не менее, привычное в использовании. Какой позор! Я предпочитаю знать, как работают технологии и протоколы, которые я использую. Поэтому я решил провести небольшое исследование, и вот, что я узнал.

Когда вы начинаете читать литературу по криптографии, вы неизбежно сталкиваетесь с большим количеством акронимов. По мере необходимости я буду их объяснять. Их знание не поможет лучше понять самой концепции, но оно будет полезно, если вы решите воспользоваться Google для их дальнейшего изучения.

Вкратце об SSH:

Если вы когда-либо использовали аутентификацию посредством открытых ключей, вероятно, у вас есть файл ~/.ssh/id_rsa или ~/.ssh/id_dsa в корневом каталоге. Это ваш закрытый ключ RSA/DSA, а ~/.ssh/id_rsa.pub или ~/.ssh/id_dsa.pub – соответствующий ему открытый ключ. На любой машине, на которую вы хотите зайти, должен быть ваш открытый ключ (~/.ssh/authorized_keys). Когда вы пытаетесь зайти на компьютер, ваш SSH клиент использует цифровую подпись в качестве доказательства, что у вас имеется закрытый ключ. Сервер проверяет, действительна ли подпись, а также подходит ли открытый ключ для вашего имени пользователя. Если все в порядке, то вам предоставляется доступ.

Что же находится непосредственно в самом файле закрытых ключей?

Формат нешифрованных закрытых ключей

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

Файл закрытых ключей SSH, как правило, выглядит следующим образом:

-----BEGIN RSA PRIVATE KEY-----
MIIEogIBAAKCAQEArCQG213utzqE5YVjTVF5exGRCkE9OuM7LCp/FOuPdoHrFUXk
y2MQcwf29J3A4i8zxpES9RdSEU6iIEsow98wIi0x1/Lnfx6jG5Y0/iQsG1NRlNCC
aydGvGaC+PwwWiwYRc7PtBgV4KOAVXMZdMB5nFRaekQ1ksdH/360KCGgljPtzTNl
09e97QBwHFIZ3ea5Eih/HireTrRSnvF+ywmwuxX4ubDr0ZeSceuF2S5WLXH2+TV0
... и т.д. ...
-----END RSA PRIVATE KEY-----

Закрытый ключ имеет структуру данных ASN.1, сериализованную к байтовой строке с помощью DER, и затем закодированную Bas64. ASN.1 че-то похож на JSON (он поддерживает различные типы данных, как целые, логические, строки, списки и последовательности, которые могут быть вложены в древовидные структуры). Он часто используется для шифрования, но почему-то с использованием web он стал выходить из моды (не знаю почему, выглядит как довольно достойный формат).

Чтобы разобраться детальнее, давайте сгенерируем поддельный нешифрованный ключ RSA с помощью ssh-keygen, а затем декодируем его с помощью asn1parse:

$ ssh-keygen -t rsa -N '' -f test_rsa_key
$ openssl asn1parse -in test_rsa_key
0:d=0 hl=4 l=1189 cons: SEQUENCE
4:d=1 hl=2 l= 1 prim: INTEGER :00
7:d=1 hl=4 l= 257 prim: INTEGER :C36EB2429D429C7768AD9D879F98C...
268:d=1 hl=2 l= 3 prim: INTEGER :010001
273:d=1 hl=4 l= 257 prim: INTEGER :A27759F60AEA1F4D1D56878901E27...
534:d=1 hl=3 l= 129 prim: INTEGER :F9D23EF31A387694F03AD0D050265...
666:d=1 hl=3 l= 129 prim: INTEGER :C84415C26A468934F1037F99B6D14...
798:d=1 hl=3 l= 129 prim: INTEGER :D0ACED4635B5CA5FB896F88BB9177...
930:d=1 hl=3 l= 128 prim: INTEGER :511810DF9AFD590E11126397310A6...
1061:d=1 hl=3 l= 129 prim: INTEGER :E3A296AE14E7CAF32F7E493FDF474...

В качестве альтернативы вы можете скопировать Base64 строку в великолепный JavaScript ASN.1 decoder Лапо Лучини. Можно отметить, что структура ASN.1 довольно простая: последовательность 9 целых чисел. Их значение определяется в RFC2313. Первое число – номер версии (0) – и 3 число – открытая экспонента е – принимают достаточно небольшие значения. Два важных числа – 2048-батные целые – под номерами 2 и 4 в последовательности: модуль RSA n и закрытая экспонента d. Эти числа используются непосредственно в самом алгоритме RSA. Остальные пять чисел могут быть получены из n и d, они являются своеобразным кэшем, и служат для увеличения скорости работы алгоритма.

Ключи DSA имеют схожую структуру, последовательность шести целых чисел:

$ ssh-keygen -t dsa -N '' -f test_dsa_key
$ openssl asn1parse -in test_dsa_key
0:d=0 hl=4 l= 444 cons: SEQUENCE
4:d=1 hl=2 l= 1 prim: INTEGER :00
7:d=1 hl=3 l= 129 prim: INTEGER :E497DFBFB5610906D18BCFB4C3CCD...
139:d=1 hl=2 l= 21 prim: INTEGER :CF2478A96A941FB440C38A86F22CF...
162:d=1 hl=3 l= 129 prim: INTEGER :83218C0CA49BA8F11BE40EE1A7C72...
294:d=1 hl=3 l= 128 prim: INTEGER :16953EA4012988E914B466B9C37CB...
425:d=1 hl=2 l= 21 prim: INTEGER :89A356E922688EDEB1D388258C825...

Зашифрованные ключи

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

$ ssh-keygen -t rsa -N 'super secret passphrase' -f test_rsa_key
$ cat test_rsa_key
-----BEGIN RSA PRIVATE KEY-----
Proc-Type: 4,ENCRYPTED
DEK-Info: AES-128-CBC,D54228DB5838E32589695E83A22595C7

3+Mz0A4wqbMuyzrvBIHx1HNc2ZUZU2cPPRagDc3M+rv+XnGJ6PpThbOeMawz4Cbu
lQX/Ahbx+UadJZOFrTx8aEWyZoI0ltBh9O5+ODov+vc25Hia3jtayE51McVWwSXg
wYeg2L6U7iZBk78yg+sIKFVijxiWnpA7W2dj2B9QV0X3ILQPxbU/cRAVTd7AVrKT
... и т.д. ...
-----END RSA PRIVATE KEY-----

Мы получили две строки заголовка, и если вы попробуете разобрать этот Base64 текст, вы увидите, что он более не ASN.1 формата. Это потому, что ASN.1 структура, показанная ранее, была зашифрована, и теперь мы видим зашифрованный Base64 текст. По заголовку можно понять, что был использован алгоритм AES-128 в режиме CBC. 128-битная шестнадцатеричная строка в DEK-Info заголовке является вектором инициализации (initialization vector - IV) для шифра. Все это довольно стандартно, любая библиотека для шифрования справится с этим.

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

  1. Добавьте первые 8 байт вектора инициализации к паролю, без разделителя (используется при солтинге)
  2. Возьмите MD5-хэш от полученной строки (один раз).

То, что мы искали! Чтобы доказать это, давайте расшифруем закрытый ключ вручную (используя вектор инициализации из вышеупомянутого DEK-Info заголовка)

$ tail -n +4 test_rsa_key | grep -v 'END ' | base64 -D | # get just the binary blob
openssl aes-128-cbc -d -iv D54228DB5838E32589695E83A22595C7 -K $(
ruby -rdigest/md5 -e 'puts Digest::MD5.hexdigest(["super secret passphrase",0xD5,0x42,0x28,0xDB,0x58,0x38,0xE3,0x25].pack("a*cccccccc"))'
) |
openssl asn1parse -inform DER

Данный скрипт печатает последовательность чисел из файла ключей RSA. Конечно, чтобы посмотреть ключ, можно было всего лишь выполнить:

$ openssl rsa -text -in test_rsa_key -passin 'pass:super secret passphrase'

Но я хотел продемонстрировать сам алгоритм получения ключа AES с помощью пароля. Это важно, потому что защита закрытого ключа имеет две уязвимости:

  • Алгоритм жестко привязан к MD5, а это в свою очередь означает, что без смены формата не получится использовать другую хэш функцию (например, SHA-1). Это может стать проблемой, если когда-нибудь MD5 окажется недостаточно эффективным.
  • Хэш функция применяется только один раз – не используется увеличение длины ключа. Это также проблема, потому что MD5 и AES являются быстро вычислимыми алгоритмами, то есть короткий пароль можно будет взломать простым брут форсом.

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

Это были плохие новости: пароль на SSH ключ полезен не настолько, как вы наверно предполагали. Но не стоит отчаиваться, вы можете сделать формат закрытого ключа более надежным!

Улучшение защиты ключа с помощью PKCS#8

Что мы действительно хотим, так это получить из пароля ключ симметричного шифрования, причем нам необходимо, чтобы его вычисление занимало действительно много времени. Тогда нарушителю потребуется больше времени, чтобы подобрать пароль простым брут форсом. Если вы видели мем «Используй bcrypt», это должно звучать очень знакомо.

Для закрытых ключей SSH существует несколько стандартов со странными названиями (внимание, ожидаются акронимы), которые помогут нам прояснить картину.

  • PKCS #5 (RFC 2898) определяет PBKDF2 (Password-Based Key Derivation Function – стандарт формирования ключа на основе пароля), алгоритм для получения ключа шифрования на основе пароля, с неоднократным применением хэш функций. PBES2 (Password-Based Encryption Scheme – схема шифрования на основе пароля) также описана здесь, она просто подразумевает использование симметричного шифрования с PBKDF2-сгенерированным ключом.
  • PKCS #8 (RFC 5208) определяет формат хранения шифрованных закрытых ключей, поддерживающих PBKDF2. OpenSSL поддерживает формат закрытых ключей PKCS#8, а OpenSSH использует OpenSSL, поэтому, если вы используете OpenSSH, вы можете заменить стандартные файлы ключей SSH для PKCS#8 и все будет работать!

Не понимаю, почему ssh-keygen все еще генерирует ключи в традиционном формате SSH, хотя уже на протяжении нескольких лет доступен новый и лучший формат. Совместимость с серверами не должна вызывать никаких опасений, потому что закрытые ключи всегда остаются на машине. К счастью, конвертация к PKCS#8 не сложная задача:

$ mv test_rsa_key test_rsa_key.old
$ openssl pkcs8 -topk8 -v2 des3 \
-in test_rsa_key.old -passin 'pass:super secret passphrase' \
-out test_rsa_key -passout 'pass:super secret passphrase'

Если вы попробуете использовать этот новый файл с клиентом SSH, вы увидите, что он работает так же, как и файл, сгенерированный ssh-keygen. Но что внутри?

$ cat test_rsa_key
-----BEGIN ENCRYPTED PRIVATE KEY-----
MIIFDjBABgkqhkiG9w0BBQ0wMzAbBgkqhkiG9w0BBQwwDgQIOu/S2/v547MCAggA
MBQGCCqGSIb3DQMHBAh4q+o4ELaHnwSCBMjA+ho9K816gN1h9MAof4stq0akPoO0
CNvXdtqLudIxBq0dNxX0AxvEW6exWxz45bUdLOjQ5miO6Bko0lFoNUrOeOo/Gq4H
dMyI7Ot1vL9UvZRqLNj51cj/7B/bmfa4msfJXeuFs8jMtDz9J19k6uuCLUGlJscP
... etc ...
-----END ENCRYPTED PRIVATE KEY-----

Обратите внимание, что заголовок и футер изменились (BEGIN ENCRYPTED PRIVATE KEY instead of BEGIN RSA PRIVATE KEY), а открытых заголовков Proc-Type и DEK-Info больше нет. По сути, весь файл ключей снова имеет структуру ASN.1

$ openssl asn1parse -in test_rsa_key
0:d=0 hl=4 l=1294 cons: SEQUENCE
4:d=1 hl=2 l= 64 cons: SEQUENCE
6:d=2 hl=2 l= 9 prim: OBJECT :PBES2
17:d=2 hl=2 l= 51 cons: SEQUENCE
19:d=3 hl=2 l= 27 cons: SEQUENCE
21:d=4 hl=2 l= 9 prim: OBJECT :PBKDF2
32:d=4 hl=2 l= 14 cons: SEQUENCE
34:d=5 hl=2 l= 8 prim: OCTET STRING [HEX DUMP]:3AEFD2DBFBF9E3B3
44:d=5 hl=2 l= 2 prim: INTEGER :0800
48:d=3 hl=2 l= 20 cons: SEQUENCE
50:d=4 hl=2 l= 8 prim: OBJECT :des-ede3-cbc
60:d=4 hl=2 l= 8 prim: OCTET STRING [HEX DUMP]:78ABEA3810B6879F
70:d=1 hl=4 l=1224 prim: OCTET STRING [HEX DUMP]:C0FA1A3D2BCD7A80DD61F4C0287F8B2D...

Для представления ASN.1 в удобочитаемом виде (дерева) используйте JavaScript ASN.1 decoder Лапо Лучини

Sequence (2 elements)
|- Sequence (2 elements)
| |- Object identifier: 1.2.840.113549.1.5.13 // using PBES2 from PKCS#5
| `- Sequence (2 elements)
| |- Sequence (2 elements)
| | |- Object identifier: 1.2.840.113549.1.5.12 // using PBKDF2 -- yay! :)
| | `- Sequence (2 elements)
| | |- Byte string (8 bytes): 3AEFD2DBFBF9E3B3 // salt
| | `- Integer: 2048 // iteration count
| `- Sequence (2 elements)
| Object identifier: 1.2.840.113549.3.7 // encrypted with Triple DES, CBC
| Byte string (8 bytes): 78ABEA3810B6879F // initialization vector
`- Byte string (1224 bytes): C0FA1A3D2BCD7A80DD61F4C0287F8B2DAB46A43E... // encrypted key blob

Формат использует OID, номерные коды, размещенные регистрирующей стороной для недвусмысленного определения алгоритма. В этом файле ключей OID показывают нам, что схема шифрования pkcs5PBES2, функция получения ключа PBKDF2, а само шифрование - des-ede3-cbc. В случае необходимости хэш функция может быть определена явно, здесь она не описывается, что значит, что используется функция по умолчанию - hMAC-SHA1.

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

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

Заключение: лучшая защита закрытых ключей SSH

Если используемый вами для закрытого ключа SSH пароль является сильным, его конвертация из традиционного ключа к формату PKCS#8 в некоторой степени сравнима с добавлением двух экстра-символов к нему. А если ваш пароль слабый, вы то данный способ позволит вам изменить защиту закрытого ключа со слабой на более сильную.

Это очень просто, можете сделать это прямо сейчас:

$ mv ~/.ssh/id_rsa ~/.ssh/id_rsa.old
$ openssl pkcs8 -topk8 -v2 des3 -in ~/.ssh/id_rsa.old -out ~/.ssh/id_rsa
$ chmod 600 ~/.ssh/id_rsa
# Check that the converted key works; if yes, delete the old one:
$ rm ~/.ssh/id_rsa.old

Команда pkcs8 использует пароль три раза: первый – чтобы получить текущий закрытый ключ, и остальные два – для генерации нового ключа. Это не важно, используете ли вы новый пароль для конвертированного ключа или сохраняете старый.

Не всякое ПО может читать формат PKCS8, но это не плохо – только ваш SSH клиент должен иметь возможность читать закрытый ключ. С точки зрения сервера хранение закрытых ключей в другом формате совершенно ничего не меняет.

Тени в интернете всегда следят за вами

Станьте невидимкой – подключайтесь к нашему каналу.