Вы когда-нибудь интересовались механизмом работы ssh-ключей? Или тем, насколько безопасно они хранятся?
Я использую ssh каждый день много раз — когда запускаю
По ходу изложения встретится много аббревиатур. Они не помогут понять идеи, но будут полезны в том случае, если вы решите погуглить подробности.
Итак, если вам доводилось прибегать к аутентификации по ключу, то у вас, скорее всего, есть файл
Что же хранится внутри закрытого ключа?
Закрытый ключ рекомендуется защищать паролем (парольной фразой), иначе злоумышленник, которому удалось украсть у вас закрытый ключ, сможет без проблем залогиниться на вашем сервере. Сначала взглянем на незашифрованный формат файла, а с зашифрованным разберемся позже.
Незашифрованный ключ выглядит примерно так:
Закрытый ключ содержит данные в формате ASN.1, представленные в виде последовательности байт согласно стандарту X.690 и закодированные в Base64. Грубо говоря, ASN.1 можно сравнить с JSON (он поддерживает различные типы данных, такие как INTEGER, BOOLEAN, строки и последовательности, которые могут формировать древовидную структуру). ASN.1 широко распространен в криптографии, хотя слегка вышел из моды с пришествием веба (я не знаю почему — он выглядит как вполне достойный формат (с этим можно поспорить — прим. пер.))
Сгенерируем тестовый RSA-ключ без пароля, используя
Структура данных в ASN.1 довольно проста: это последовательность из девяти целых чисел. Их назначение определено в RFC2313. Первое и третье числа — это номер версии (0) и открытая экспонента e. Второе и четвертое числа (длиной 2048 бит) — это модуль n и секретная экспонента d. Эти числа являются параметрами ключа RSA. Остальные пять можно получить, зная n и d — они кэшированы в файле для ускорения некоторых операций.
Структура DSA-ключей похожа и включает шесть чисел:
Теперь усложним жизнь потенциальному злоумышленнику, который смог украсть закрытый ключ — защитим его паролем. Что произошло с файлом?
Заметим, что добавились две строки с заголовками, а результат декодирования Base64-строки больше не является валидным ASN.1. Дело в том, что структура ASN.1. зашифрована. Из заголовков узнаем, какой алгоритм использовался для шифрования: AES-128 в режиме CBC. 128-битная шестнадцатеричная строка в заголовке
Но как из пароля получается ключ AES? Я не нашел этого в документации и поэтому был вынужден разбираться в исходниках OpenSSL. Вот что я выяснил насчет получения ключа шифрования:
Для проверки расшифруем закрытый ключ, взяв вектор инициализации из заголовка
Эта команда выведет параметры ключа RSA. Если вы хотите просто увидеть ключ, есть способ и проще:
Но я хотел показать, как именно ключ AES получается из пароля, чтобы обратить внимание на два уязвимых места:
Если ssh-ключ попадет в недобрые руки, например, кто-нибудь украдет ваш ноутбук или жеский диск с бэкапами, злоумышленник сможет перебрать большое количество паролей, даже обладая небольшой вычислительной мощностью. Если вы установили словарный пароль, его можно подобрать за секунды.
Это плохая новость: защита ключа паролем не так хороша, как можно было предположить. Но есть и хорошая новость: вы можете перейти на более надежный формат закрытого ключа.
Итак, нам нужен алгоритм получения симметричного ключа шифрования из пароля, который работал бы медленно, чтобы злоумышленнику потребовалось больше вычислительного времени для подбора пароля.
Для ssh-ключей существует несколько стандартов с неуклюжими названиями:
Я не знаю, почему
Если попробовать использовать новый файл ключа в формате PKCS#8, можно обнаружить, что все работает так же как и раньше. Посмотрим, что теперь находится внутри файла.
Обратите внимание на то, что первая и последняя строки изменились (
Воспользуемся JavaScript-декодером, чтобы рассмотреть структуру ASN.1:
Здесь упоминаются OID (Object identifier) — глобально-уникальные цифровые идентификаторы. По ним мы узнаем, что используется схема шифрования pkcs5PBES2, функция получения ключа PBKDF2 и алгоритм шифрования des-ede3-cbc. Функция хеширования не указана явно, значит, по умолчанию используется hMAC-SHA1.
Хранение OID в файле хорошо тем, что ключи можно обновить без смены формата контейнера (если, например, будет изобретен лучший алгоритм шифрования).
Также мы видим, что в ходе получения ключа шифрования выполняется 2048 итераций. Это гораздо лучше, чем однократное применение хеш-функции при использовании традиционного формата ssh-ключей — перебор паролей потребует больше времени. В настоящий момент количество итераций прописано в коде OpenSSL, я надеюсь, в будущем его можно будет настраивать.
Если вы установили сложный пароль на закрытый ключ, то преобразование его из традиционного формата в PKCS#8 можно сравнить с увеличением длины пароля на пару символов. Если же вы используете слабый пароль, PKCS#8 сделает его подбор заметно сложнее.
Поменять формат ключей очень просто:
Команда
Не весь софт может читать формат PKCS#8, но в этом нет ничего страшного — доступ к закрытому ssh-ключу нужен только ssh-клиенту. С точки зрения сервера, хранение закрытого ключа в другом формате вообще ничего не меняет.
Переводчик будет рад услышать замечания и конструктивную критику.
Я использую ssh каждый день много раз — когда запускаю
git fetch
или git push
, когда развертываю код или логинюсь на сервере. Не так давно я осознал, что для меня ssh стал магией, которой я привык пользоваться без понимация принципов ее работы. Мне это не сильно понравилось — я люблю разбираться в инструментах, которые использую. Поэтому я провел небольшое исследование и делюсь с вами результатами.По ходу изложения встретится много аббревиатур. Они не помогут понять идеи, но будут полезны в том случае, если вы решите погуглить подробности.
Итак, если вам доводилось прибегать к аутентификации по ключу, то у вас, скорее всего, есть файл
~/.ssh/id_rsa
или ~/.ssh/id_dsa
в домашнем каталоге. Это закрытый (он же приватный) RSA/DSA ключ, а ~/.ssh/id_rsa.pub
или ~/.ssh/id_dsa.pub
— открытый (он же публичный) ключ. На сервере, на котором вы хотите залогиниться, должна быть копия открытого ключа в ~/.ssh/authorized_keys
. Когда вы пытаетесь залогиниться, ssh-клиент подтвержает, что у вас есть закрытый ключ, используя цифровую подпись; сервер проверяет, что подпись действительна и в ~/.ssh/authorized_keys
есть открытый ключ, и вы получаете доступ.Что же хранится внутри закрытого ключа?
Незашифрованный формат закрытого ключа
Закрытый ключ рекомендуется защищать паролем (парольной фразой), иначе злоумышленник, которому удалось украсть у вас закрытый ключ, сможет без проблем залогиниться на вашем сервере. Сначала взглянем на незашифрованный формат файла, а с зашифрованным разберемся позже.
Незашифрованный ключ выглядит примерно так:
-----BEGIN RSA PRIVATE KEY-----
MIIEogIBAAKCAQEArCQG213utzqE5YVjTVF5exGRCkE9OuM7LCp/FOuPdoHrFUXk
y2MQcwf29J3A4i8zxpES9RdSEU6iIEsow98wIi0x1/Lnfx6jG5Y0/iQsG1NRlNCC
aydGvGaC+PwwWiwYRc7PtBgV4KOAVXMZdMB5nFRaekQ1ksdH/360KCGgljPtzTNl
09e97QBwHFIZ3ea5Eih/HireTrRSnvF+ywmwuxX4ubDr0ZeSceuF2S5WLXH2+TV0
... и так далее ...
-----END RSA PRIVATE KEY-----
Закрытый ключ содержит данные в формате ASN.1, представленные в виде последовательности байт согласно стандарту X.690 и закодированные в Base64. Грубо говоря, ASN.1 можно сравнить с JSON (он поддерживает различные типы данных, такие как INTEGER, BOOLEAN, строки и последовательности, которые могут формировать древовидную структуру). ASN.1 широко распространен в криптографии, хотя слегка вышел из моды с пришествием веба (я не знаю почему — он выглядит как вполне достойный формат (с этим можно поспорить — прим. пер.))
Сгенерируем тестовый RSA-ключ без пароля, используя
ssh-keygen
, и декодируем его с помощью asn1parse
(или воспользуемся написанным на JavaScript ASN.1-декодером):$ 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...
Структура данных в ASN.1 довольно проста: это последовательность из девяти целых чисел. Их назначение определено в RFC2313. Первое и третье числа — это номер версии (0) и открытая экспонента e. Второе и четвертое числа (длиной 2048 бит) — это модуль 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. зашифрована. Из заголовков узнаем, какой алгоритм использовался для шифрования: AES-128 в режиме CBC. 128-битная шестнадцатеричная строка в заголовке
DEK-Info
— это вектор инициализации (IV). Ничего необычного здесь нет, все распространенные криптографические библиотеки умеют работать с используемыми здесь алгоритмами.Но как из пароля получается ключ AES? Я не нашел этого в документации и поэтому был вынужден разбираться в исходниках OpenSSL. Вот что я выяснил насчет получения ключа шифрования:
- Первые 8 байт вектора инициализации дописываются к паролю (по сути, являются солью).
- От полученной строки один раз берется MD5-хеш.
Для проверки расшифруем закрытый ключ, взяв вектор инициализации из заголовка
DEK-Info
:$ tail -n +4 test_rsa_key | grep -v 'END ' | base64 -d |
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-ключ попадет в недобрые руки, например, кто-нибудь украдет ваш ноутбук или жеский диск с бэкапами, злоумышленник сможет перебрать большое количество паролей, даже обладая небольшой вычислительной мощностью. Если вы установили словарный пароль, его можно подобрать за секунды.
Это плохая новость: защита ключа паролем не так хороша, как можно было предположить. Но есть и хорошая новость: вы можете перейти на более надежный формат закрытого ключа.
Повышаем защиту ключа с использованием PKCS#8
Итак, нам нужен алгоритм получения симметричного ключа шифрования из пароля, который работал бы медленно, чтобы злоумышленнику потребовалось больше вычислительного времени для подбора пароля.
Для ssh-ключей существует несколько стандартов с неуклюжими названиями:
- В PKCS #5 (RFC 2898) определен алгоритм PBKDF2 (Password-Based Key Derivation Function 2) получения ключа шифрования из пароля путем многократного применения хеш-функции. Там же определена схема шифрования PBES2 (Password-Based Encryption Scheme 2), которая включает использование ключа, сгенерированного по PBKDF2, и симметричного шифра.
- В PKCS #8 (RFC 5208) определен формат хранения зашифрованных закрытых ключей с поддержкой PBKDF2. OpenSSL поддерживает закрытые ключи в формате PKCS#8, а OpenSSH использует OpenSSL, так что если вы пользуетесь OpenSSH, то можете переключиться с традиционного формата файлов ssh-ключей на формат PKCS#8.
Я не знаю, почему
ssh-keygen
до сих пор генерирует ключи в традиционном формате, несмотря на то, что уже много лет существуют лучшие альтернативы. Дело не в совместимости с серверным софтом: закрытые ключи никогда не покидают пределы вашего компьютера. К счастью, существующие ключи достаточно легко преобразовать в формат 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'
Если попробовать использовать новый файл ключа в формате PKCS#8, можно обнаружить, что все работает так же как и раньше. Посмотрим, что теперь находится внутри файла.
$ cat test_rsa_key
-----BEGIN ENCRYPTED PRIVATE KEY-----
MIIFDjBABgkqhkiG9w0BBQ0wMzAbBgkqhkiG9w0BBQwwDgQIOu/S2/v547MCAggA
MBQGCCqGSIb3DQMHBAh4q+o4ELaHnwSCBMjA+ho9K816gN1h9MAof4stq0akPoO0
CNvXdtqLudIxBq0dNxX0AxvEW6exWxz45bUdLOjQ5miO6Bko0lFoNUrOeOo/Gq4H
dMyI7Ot1vL9UvZRqLNj51cj/7B/bmfa4msfJXeuFs8jMtDz9J19k6uuCLUGlJscP
... десу-десу ...
-----END ENCRYPTED PRIVATE KEY-----
Обратите внимание на то, что первая и последняя строки изменились (
BEGIN ENCRYPTED PRIVATE KEY
вместо 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...
Воспользуемся JavaScript-декодером, чтобы рассмотреть структуру ASN.1:
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 (Object identifier) — глобально-уникальные цифровые идентификаторы. По ним мы узнаем, что используется схема шифрования pkcs5PBES2, функция получения ключа PBKDF2 и алгоритм шифрования des-ede3-cbc. Функция хеширования не указана явно, значит, по умолчанию используется hMAC-SHA1.
Хранение OID в файле хорошо тем, что ключи можно обновить без смены формата контейнера (если, например, будет изобретен лучший алгоритм шифрования).
Также мы видим, что в ходе получения ключа шифрования выполняется 2048 итераций. Это гораздо лучше, чем однократное применение хеш-функции при использовании традиционного формата ssh-ключей — перебор паролей потребует больше времени. В настоящий момент количество итераций прописано в коде OpenSSL, я надеюсь, в будущем его можно будет настраивать.
Заключение
Если вы установили сложный пароль на закрытый ключ, то преобразование его из традиционного формата в PKCS#8 можно сравнить с увеличением длины пароля на пару символов. Если же вы используете слабый пароль, 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
# проверьте, что новый ключ работает. если работает, старый можно удалить
$ rm ~/.ssh/id_rsa.old
Команда
openssl pkcs8
запрашивает пароль три раза: один раз для разблокировки существующего ключа и два раза при создании нового файла ключа. Вы можете придумать новый пароль или использовать старый, это не имеет никакого значения.Не весь софт может читать формат PKCS#8, но в этом нет ничего страшного — доступ к закрытому ssh-ключу нужен только ssh-клиенту. С точки зрения сервера, хранение закрытого ключа в другом формате вообще ничего не меняет.
Переводчик будет рад услышать замечания и конструктивную критику.