Генерация приватных ключей Ed25519 на Go

Nikolay Matrosov
4 min readAug 6, 2022

Небольшая предыстория. Мне понадобилось собрать Ubuntu 22.04 Jammy Jellyfish при помощи Packer используя ssh provisioner. А для дебага этого процесса захотелось научиться сохранять создаваемые пакером ключи на диск, чтобы с их помощь можно было зайти на ВМ.

Начиная с версии 8.7 (а в jammy стоит 8.9) OpenSSH считает небезопасными ключи ssh-rsa и отключает их по умолчанию.

В протоколе SSH схема подписи ssh-rsa использует SHA-1
алгоритм хэширования в сочетании с алгоритмом открытого ключа RSA. Сейчас можно выполнять атаки с выбранным префиксом против алгоритма SHA-1 менее чем за 50 тысяч долларов США.

Стоит отметить, что деактивация подписей ssh-rsa необязательно
требует прекращения использования ключей RSA. В протоколе SSH ключи могут быть подписаны с использованием нескольких алгоритмов. В частности, ssh-rsaключи могут подписываться с использованием rsa-sha2–256 (RSA/SHA256), rsa-sha2–512(RSA/SHA512) и ssh-rsa (RSA/SHA1). Так вот по умолчанию отключен только последний из них.

Но так как Packer написан на Go, а в Go до сих пор не поддержаны нормально разрешенные алгоритмы подписи RSA ключей, мой выбор пал на формат ключей ssh-ed25519, так как он входит в список рекомендованных OpenSSH альтернатив и поддерживается в Go.

Одна загвоздка. Go умеет создавать такие ключи. Но совершенно не озаботился включить в стандартную библиотеку метод для сериализации ключа в формат, совместимый с OpenSSH.

Отправной точкой для меня стал метод в SDK Packer’а. К сожалению автор честно признается в комментарии к коду, что его ключи при использовании в OpenSSH выдают ошибку:

PairFromED25519 marshalls a valid pair of openssh pem for ED25519 keypairs.
NewPair can handle ed25519 pairs but generates the wrong format apparently:
`Load key "id_ed25519": invalid format` is the error that happens when I try to ssh with such a key.

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

RFC 4253, раздел 6.6 описывает формат открытых ключей OpenSSH, и, следуя этому RFC, довольно легко реализовать синтаксический анализатор и декодировать различные биты, составляющие открытый ключ OpenSSH.

Однако, в отличие от открытых ключей OpenSSH, не существует документа RFC, описывающего бинарный формат закрытых ключей, которые генерируются ssh-keygen.

Ответ, как это не удивительно, был найден в репозитории OpenSSH.

1. Overall formatThe key consists of a header, a list of public keys, and
an encrypted list of matching private keys.
#define AUTH_MAGIC "openssh-key-v1" byte[] AUTH_MAGIC
string ciphername
string kdfname
string kdfoptions
uint32 number of keys N
string publickey1
string publickey2
...
string publickeyN
string encrypted, padded list of private keys

Сначала дан общий обзор формата. Далее подробнее разбирается возможные значения для полей.

2. KDF options for kdfname "bcrypt"The options: string salt
uint32 rounds
are concatenated and represented as a string.

Кстати, кроме значения bcrypt можно также использовать none(об этом говорится в пункте 5). Что мы и будем делать, так как это проще, а у меня нет цели написать сериализатор, который будет поддерживать все возможные кейсы.

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

3. Unencrypted list of N private keysThe list of privatekey/comment pairs is padded with the
bytes 1, 2, 3, ... until the total length is a multiple
of the cipher block size.
uint32 checkint
uint32 checkint
byte[] privatekey1
string comment1
byte[] privatekey2
string comment2
...
string privatekeyN
string commentN
byte 1
byte 2
byte 3
...
byte padlen % 255
where each private key is encoded using the same rules as used for
SSH agent.
Before the key is encrypted, a random integer is assigned
to both checkint fields so successful decryption can be
quickly checked by verifying that both checkint fields
hold the same value.

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

Обратите внимание на паддинг. Мы к нему еще вернемся чуть позже.

Далее приведены опции для шифрованных и нешифрованных ключей. И нам интересна именно последняя.

4. EncryptionThe KDF is used to derive a key, IV (and other values required by
the cipher) from the passphrase. These values are then used to
encrypt the unencrypted list of private keys.
5. No encryptionFor unencrypted keys the cipher "none" and the KDF "none"
are used with empty passphrases. The options if the KDF "none"
are the empty string.

Как видно функция для сериализации ключа в многом повторяет описанный формат, но есть некоторые отличия.

Для того чтобы наглядно убедиться, что результат работы ssh-keygen и go совпадает, я поставил https://github.com/WerWolv/ImHex. (Про который услышал в последнем выпуске Радио-Т)

На скриншоте видно, что это уже моя шестая попытка генерации ключа.

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

Оставалось только разобраться, какой же паддинг ожидается. В пункте 3 говорится, что нужно добиться кратности размеру блока шифрования. Но так как у нас нет шифрования, то непонятно какой размер ожидается. К счастью в статье такого же исследователя приватных ed25519 ключей, как и я, я нашел подсказку. Допустимые значения шифров и размеры блоков описаны в cipher.c.

И как видно для нашего none размер блока должен быть 8.

С этими исправлениями у мен получилось собрать рабочее решение.

Ну и напоследок интересный факт: номер в названии алгоритма обозначает кривую на которой он основан — Curve25519. А она в свою очередь так названа, потому что является Кривой Монтгомери над простым полем, определяемым простым числом 2²⁵⁵-19.

Upd: pull request с этими изменениями приняли и он попал в релиз SDK 0.3.2.

--

--