В статье хочу рассказать опыт изучения Certificate Signing Request (CSR) формата. О том, что такое PEM, DER, какова структура приватных и публичных ключей, самого CSR файла и самое страшное - как этот CSR подписывается. Итак...

0. С чего всё началось

Есть у меня маленький домашний Web-сервер. Долгие годы работал он на одном лишь HTTP, но вот решил я обуздать SSL и бесплатные сертификаты от Let's Encrypt, чтобы в дальнейшем и телеграм ботов на нём настраивать, и PWA, и прочие радости HTTPS жизни.

Потратил вечер-другой на подбор приятного в работе ACME клиента, коим стал написанный на bash bacme, настройку сервера на прохождение проверок, редиректы с HTTP на HTTPS - и вот сервисы моего сервера уже имеют желанный замочек в адресной строке.

Проблемы начались с автоматизации этого процесса. Так как сертификаты сервис раздаёт всего на три месяца, нужно озаботиться их регулярным обновлением. С этой целью я полез в документацию ACME протокола и начал писать свой скрипт. Детали о том, как генерировались ключи и JWS запросы, опять же, опущу. Самая весёлая часть началась, когда я решил генерировать свои CSR.

1. Разбираем структуру PEM и DER в целом, и CSR в частности

От простого к сложному

1.1. Что такое PEM

Privacy-Enhanced Mail - формат изначально созданный для хранения криптографических данных с целью повышения безопасности обмена электронной почтой (ибо сам протокол изначально о безопасности вообще ничего не знает).

Многие из нас не раз генерировали пары закрытых/открытых ключей через openssl. Давайте сгенерируем одну пару и будем далее от них отталкиваться:

$ openssl genrsa 512 > private.key
$ cat private.key | openssl rsa -pubout > public.key

Длина специально выбрана минимальная, чтобы и места в тексте меньше занимало, но и для подписания CSR хватало. В продакшене, разумеется, размер нужно указывать значительно толще.

Итого имеем

-----BEGIN RSA PRIVATE KEY-----
MIIBPAIBAAJBAK1RiSg7+E2s2tyCtj/63IXk9F8nT44+PBOyoB8rfyztkUWA/zhA
jYzVO+DHl/cccA996OaW5xxDz2LeX2D6cokCAwEAAQJBAIw1+/l6mmNsRRpC/GFB
9oizMiaQTMHMAxoEVZkhvR6ANBZdMqMh0Zg5gQ8UgOiQcPXKK9KjtmVLqNsYiFx8
3gkCIQDlA/DOMvkqBimYXWdnhgv/H/YMv9ZCyTxtWPgQZJz/3wIhAMG9i2WQNCN/
0XqLglY3DqM4FDpN3bHyFLp0laU6eTqXAiAfV3e4MH+rAablpDrHjy/LHYul2Qcw
oquzZ06jp7FYzwIhAKAXCfrQn+S9l9FVOkwXjqbcjgpnkUubJ/myoH05xjbdAiEA
zAkmtBlPsk6InUsepCYFp5CLJJ22h6n5QUtp3yDdnb4=
-----END RSA PRIVATE KEY-----
-----BEGIN PUBLIC KEY-----
MFwwDQYJKoZIhvcNAQEBBQADSwAwSAJBAK1RiSg7+E2s2tyCtj/63IXk9F8nT44+
PBOyoB8rfyztkUWA/zhAjYzVO+DHl/cccA996OaW5xxDz2LeX2D6cokCAwEAAQ==
-----END PUBLIC KEY-----

Вряд ли кому нужно объяснять, что мы видим нечто закодированное в base64 между хедером и футером вида -----BEGIN [TYPE]----- и -----END [TYPE]----- - это и есть PEM контейнер. По сути, там могут содержаться данные любого вида, но сейчас меня интересуют всякие крипто-ништяки, а это бинарные данные, классически представленные в DER формате.

1.2. Что такое DER

Distinguished Encoding Rules - один из способов представления ASN.1 данных, вероятно наиболее распространённый.

Переведём для начала наш публичный ключ (он самый маленький) из base64 в HEX

30 5c 30 0d 06 09 2a 86  48 86 f7 0d 01 01 01 05
00 03 4b 00 30 48 02 41  00 ad 51 89 28 3b f8 4d
ac da dc 82 b6 3f fa dc  85 e4 f4 5f 27 4f 8e 3e
3c 13 b2 a0 1f 2b 7f 2c  ed 91 45 80 ff 38 40 8d
8c d5 3b e0 c7 97 f7 1c  70 0f 7d e8 e6 96 e7 1c
43 cf 62 de 5f 60 fa 72  89 02 03 01 00 01

DER формат имеет формат TLV (Type, Length, Value). В нашем случае Type - всегда один байт, Length - от одного байта и выше (больше трёх пока не встречал, но спецификацией допускается до 128 байт), Value - <Length> последующих байт.

Все типы перечислять не буду, но нас сейчас наиболее интересуют следующие:

02

INTEGER

Целое число

03

BIT STRING

Последовательность битов

04

OCTET STRING

Последовательность байтов

05

NULL

Пустое значение

06

OBJECT

Объект в OID базе данных

0c

UTF8 STRING

Строка в кодировке UTF8

30

SEQUENCE

Упорядоченная последовательность элементов

31

SET

Последовательность элементов без определённого порядка

Длина рассчитывается по следующему принципу:

  1. Если значение первого байта между 0 и 127 включительно, то это и есть длина содержимого, начинающаяся со следующего байта.

  2. Если первый бит установлен в единицу (то есть значение > 127), то мы исключаем эту единицу, а полученное значение - это сколько следующих байт содержат целочисленное значение длины, вычитываем значение из них.

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

30 5c (SEQUENSE - 92)
  30 0d (SEQUENCE - 13)
    06 09 (OBJECT - 9)
      2a 86 48 86 f7 0d 01 01 01 (rsaEncryption)
    05 00 (NULL - 0)
  03 4b (BIT STRING - 75)
    00 30 48 02 41 00 ad 51 89 28 3b f8 4d ac da dc 82 b6 3f fa dc ...

Внимательно присмотревшись, можно увидеть, что данный BIT STRING вполне также можно распарсить:

30 48 (SEQUENCE - 72)
  02 41 (INTEGER - 65),
    00 ad 51 89 28 3b f8 4d ac da dc 82 b6 3f ... (очень большое число, приводить к базе 10 не буду)
  02 03 (INTEGER - 3),
    01 00 01 (65537)

Это зашифрованные в нашем ключе modulus и exponent. Те самые, которые можно получить командой openssl rsa -pubin -text. Абсолютно аналогично можно получить все необходимые данные из нашего приватного ключа:

30 82 01 3c (SEQUENCE - 316)
  02 01 (INTEGER - 1)
    00 (0)
  02 41 (INTEGER - 65)
    00 ad 51 89 28 3b f8 4d ac da dc 82 b6 3f fa dc 85 e4 f4 5f 27 4f 8e 3e 3c 13 b2 a0 1f 2b 7f 2c  ...
  02 03 (INTEGER - 3)
    01 00 01 (65537)
  02 41 (INTEGER - 65)
    00 8c 35 fb f9 7a 9a 63 6c 45 1a 42 fc 61 41 f6 88 b3 32 26 90 4c c1 cc 03 1a 04 55 99 21 bd 1e  ...
  02 21 (INTEGER - 33)
    00 e5 03 f0 ce 32 f9 2a 06 29 98 5d 67 67 86 0b ff 1f f6 0c bf d6 42 c9 3c 6d 58 f8 10 64 9c ff  ...
  02 21 (INTEGER - 33)
    00 c1 bd 8b 65 90 34 23 7f d1 7a 8b 82 56 37 0e a3 38 14 3a 4d dd b1 f2 14 ba 74 95 a5 3a 79 3a  ...
  02 20 (INTEGER - 32)
    1f 57 77 b8 30 7f ab 01 a6 e5 a4 3a c7 8f 2f cb 1d 8b a5 d9 07 30 a2 ab b3 67 4e a3 a7 b1 58 cf
  02 21 (INTEGER - 33)
    00 a0 17 09 fa d0 9f e4 bd 97 d1 55 3a 4c 17 8e a6 dc 8e 0a 67 91 4b 9b 27 f9 b2 a0 7d 39 c6 36  ...
  02 21 (INTEGER - 33)
    00 cc 09 26 b4 19 4f b2 4e 88 9d 4b 1e a4 26 05 a7 90 8b 24 9d b6 87 a9 f9 41 4b 69 df 20 dd 9d  ...

Здесь мы видим наши modulus и exponent, которые как раз экспортируются в public.key при создании публичного ключа (`openssl rsa -pubout`):

  02 41 (INTEGER - 65)
    00 ad 51 89 28 3b f8 4d ac da dc 82 b6 3f fa dc 85 e4 f4 5f 27 4f 8e 3e 3c 13 b2 a0 1f 2b 7f 2c  ...
  02 03 (INTEGER - 3)
    01 00 01 (65537)

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

1.3. Из чего сделан CSR

Для начала сгенерируем наш запрос с помощью `openssl`:

openssl req -sha256 -new -key private.key -subj "/CN=asd" > req.csr
-----BEGIN CERTIFICATE REQUEST-----
MIHHMHMCAQAwDjEMMAoGA1UEAwwDYXNkMFwwDQYJKoZIhvcNAQEBBQADSwAwSAJB
AK1RiSg7+E2s2tyCtj/63IXk9F8nT44+PBOyoB8rfyztkUWA/zhAjYzVO+DHl/cc
cA996OaW5xxDz2LeX2D6cokCAwEAAaAAMA0GCSqGSIb3DQEBCwUAA0EAMKHzrPNG
tceXiSuFt4laz0nFvt8psAcjhL/Ke3Zf/xDvLIum1Li7Q26qoRAqst90+Ux+I61w
VqrwHTo2lEzU+Q==
-----END CERTIFICATE REQUEST-----

И посмотрим на его структуру

30 81 c7 (SEQUENCE - 199)
  30 73 (SEQUENCE - 115)
    02 01 (INTEGER - 1) // Version
      00 (0)
    30 0e (SEQUENCE - 14) // Subject
      31 0c (SET - 12)
        30 0a (SEQUENCE - 10)
          06 03 (OBJECT - 3)
            55 04 03 (commonName)
          0c 03 (UTF8 - 3)
            61 73 64 (asd)
    30 5c (SEQUENCE - 92) // RSA Public Key
      30 0d (SEQUENCE - 13)
        06 09 (OBJECT - 9)
          2a 86 48 86 f7 0d 01 01 01 (rsaEncryption)
        05 00 (NULL - 0)
      03 4b (BIT - 75)
        00 30 48 02 41 00 ad 51 89 28 3b f8 4d ac da dc 82 b6 3f fa dc 85 e4 f4 5f 27 4f 8e 3e 3c 13 b2  ...
    a0 00 (BER - 0) // Extended attribute list
  30 0d (SEQUENCE - 13)
    06 09 (OBJECT - 9) // Signature algorithm
      2a 86 48 86 f7 0d 01 01 0b (sha256WithRSAEncryption)
    05 00 (NULL - 0)
  03 41 (BIT - 65) // Signature
    00 30 a1 f3 ac f3 46 b5 c7 97 89 2b 85 b7 89 5a cf 49 c5 be df 29 b0 07 23 84 bf ca 7b 76 5f ff  ...

Основные части:

  1. Version - на данный момент всегда 0.

  2. Subject - основные поля запроса, вроде commonName, countryName и прочие поля по желанию заказчика или требованию CA.

  3. RSA Public Key - точная копия публичного ключа

  4. Extended attribute list - Группа дополнительных полей, например `Subject Alternative Names` (Не изучал, что может быть ещё)

  5. Signature algorithm - Сообщаем CA, каким методом мы подписываем наш запрос

  6. Signature - сама подпись

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

2. Подписываем CSR

(Если нет желания читать про творческие муки, а интересуют сухие технические сведения - смело пропускайте эту часть)

Провозился с этим делом я около недели свободных вечеров в попытках либо подписать, либо свалидировать существующую подпись. Чтение документации ни к чему не приводило, гугление приводило куда угодно, но ни одно из решений не отвечало на вопрос в контексте CSR, спрашивал в рабочих чатах по бэкендерам - не отвечает никто. Пошёл формулировать подробный вопрос на StackOverflow, параллельно пробуя ещё раз.

Сам вопрос я так и не задал, так как удалось найти ошибку в процессе его описания, но, если кому интересно, положу его шаблон под спойлер, а далее перескажу на родном русском, опустив всё то, что уже описано выше.

Шаблон неотправленного вопроса на StackOverflow

I'm diving into cryptography algorithms with current task of getting certificates from Let's Encrypt by ACME protocol. At a step of generating Certificate Signing Request (CSR) I've encountered behaviour I can't understand: how document signature is generated and why signature generated by me doesn't match the one generated by openssl req -new.

For my test I've generated key pair:

$ openssl genrsa 512 > private.key

-----BEGIN RSA PRIVATE KEY-----
MIIBPAIBAAJBAK1RiSg7+E2s2tyCtj/63IXk9F8nT44+PBOyoB8rfyztkUWA/zhA
jYzVO+DHl/cccA996OaW5xxDz2LeX2D6cokCAwEAAQJBAIw1+/l6mmNsRRpC/GFB
9oizMiaQTMHMAxoEVZkhvR6ANBZdMqMh0Zg5gQ8UgOiQcPXKK9KjtmVLqNsYiFx8
3gkCIQDlA/DOMvkqBimYXWdnhgv/H/YMv9ZCyTxtWPgQZJz/3wIhAMG9i2WQNCN/
0XqLglY3DqM4FDpN3bHyFLp0laU6eTqXAiAfV3e4MH+rAablpDrHjy/LHYul2Qcw
oquzZ06jp7FYzwIhAKAXCfrQn+S9l9FVOkwXjqbcjgpnkUubJ/myoH05xjbdAiEA
zAkmtBlPsk6InUsepCYFp5CLJJ22h6n5QUtp3yDdnb4=
-----END RSA PRIVATE KEY-----

$ cat private.key | openssl rsa -pubout > public.key

-----BEGIN PUBLIC KEY-----
MFwwDQYJKoZIhvcNAQEBBQADSwAwSAJBAK1RiSg7+E2s2tyCtj/63IXk9F8nT44+
PBOyoB8rfyztkUWA/zhAjYzVO+DHl/cccA996OaW5xxDz2LeX2D6cokCAwEAAQ==
-----END PUBLIC KEY-----

Then generated CSR signed by private.key:

openssl req -sha256 -new -key private.key -subj "/CN=asd" > req.csr

Documentation of CSR:

 The signature process consists of two steps:

        1. The value of the certificationRequestInfo component is DER
           encoded, yielding an octet string.

        2. The result of step 1 is signed with the certification request
           subject's private key under the specified signature
           algorithm, yielding a bit string, the signature.

   Note - An equivalent syntax for CertificationRequest could be
   written:

   CertificationRequest ::= SIGNED { EncodedCertificationRequestInfo }
        (CONSTRAINED BY { -- Verify or sign encoded
         -- CertificationRequestInfo -- })

   EncodedCertificationRequestInfo ::=
        TYPE-IDENTIFIER.&Type(CertificationRequestInfo)

   SIGNED { ToBeSigned } ::= SEQUENCE {
        toBeSigned ToBeSigned,
        algorithm  AlgorithmIdentifier { {SignatureAlgorithms} },
        signature  BIT STRING
   }

I've got my utility to parse and read PEM with DER and ASN.1, so I've extracted all parts of req.csr (converted to HEX):

toBeSigned (all data without header (type + length)):
020100300e310c300a06035504030c03617364305c300d06092a864886f70d0101010500034b003048024100ad5189283bf84dacdadc82b63ffadc85e4f45f274f8e3e3c13b2a01f2b7f2ced914580ff38408d8cd53be0c797f71c700f7de8e696e71c43cf62de5f60fa72890203010001a000

algorithm (simply: sha256WithRSAEncryption):
06092a864886f70d01010b0500

signature (BIT STRING, so I trimmed first 0x00 byte):
30a1f3acf346b5c797892b85b7895acf49c5bedf29b0072384bfca7b765fff10ef2c8ba6d4b8bb436eaaa1102ab2df74f94c7e23ad7056aaf01d3a36944cd4f9

Now where is fun part begins. For my experiments I'm using NodeJS built-in crypto library.

My main goal is to generate signature myself, so let's try doing it right away.

const privateKey = fs.readFileSync('./private.key');
const sign = crypto.createSign('SHA256'); // 'sha256WithRSAEncryption' yields same result
sign.update(toBeSigned);
console.log(sign.sign(privateKey).toString('hex'));

// 257ea64e1e26ff56a13941aa8dc2f3920ce160ef8229d3096f71d41743f1072da7f6cf6471eab78fac73667ea349f73b34e022c2ed420c538a01d833656b018a

That doesn't really match signature generated by openssl.

Tried to do straight forward as I understand from documentation (mentioned above):

const hash = crypto.createHash('SHA256');
hash.update(toBeSigned);
console.log(crypto.privateEncrypt(privateKey, hash.digest()).toString('hex'));

// 22f7dfb2d78a7d782526bdb5a209d8829ea15b21c694d6beaf057c4f267d4c10b15572cd63cfb86429b9b019216a1e214d05b62a90121f2e174cd5b2357dbe2c

This doesn't match neither CSR signature, nor output of my first attempt.

Ok, let's just try to verify signature.

const publicKey = extractPublicKey(toBeSigned); // ensured that it's matched openssl generated public.key mentioned above

const verify = crypto.createVerify('SHA256'); // or 'sha256WithRSAEncryption' - same result
verify.update(toBeSigned);
console.log(verify.verify(publicKey, signature));

// false

Let's just try to decrypt signature, if it actually encrypted SHA256 hash.

const hash = crypto.createHash('SHA256');
hash.update(toBeSigned);
console.log('HASH:', hash.digest().toString('hex'));
const decrypt = crypto.publicDecrypt(publicKey, signature);
console.log('DECRYPT:', decrypt.toString('hex'));

// HASH: 1dadbc87fb115c377a9c7055739b6bf71666cd3d6f13923406e321addef9f4a9
// DECRYPT: 3031300d060960864801650304020105000420d55d923fe33e4dcb1446bf8680763c49583c59d71a81adafdd611f4aa5fe2b10

Needless to say they doesn't match. Also the length of decrypted sign is far from 256 bits. I sliced first 19 bytes (that exceeded 256 bits) to take a closer look.

30 31 30 0d 06 09 60 86 48 01 65 03 04 02 01 05 00 04 20

It's definitely looks like DER encoded line

Сначала обратимся к документации

 The signature process consists of two steps:

        1. The value of the certificationRequestInfo component is DER
           encoded, yielding an octet string.

        2. The result of step 1 is signed with the certification request
           subject's private key under the specified signature
           algorithm, yielding a bit string, the signature.

   Note - An equivalent syntax for CertificationRequest could be
   written:

   CertificationRequest ::= SIGNED { EncodedCertificationRequestInfo }
        (CONSTRAINED BY { -- Verify or sign encoded
         -- CertificationRequestInfo -- })

   EncodedCertificationRequestInfo ::=
        TYPE-IDENTIFIER.&Type(CertificationRequestInfo)

   SIGNED { ToBeSigned } ::= SEQUENCE {
        toBeSigned ToBeSigned,
        algorithm  AlgorithmIdentifier { {SignatureAlgorithms} },
        signature  BIT STRING
   }

Получим из запроса необходимые данные:

toBeSigned (все данные, исключая заголовок):
020100300e310c300a06035504030c03617364305c300d06092a864886f70d0101010500034b003048024100ad5189283bf84dacdadc82b63ffadc85e4f45f274f8e3e3c13b2a01f2b7f2ced914580ff38408d8cd53be0c797f71c700f7de8e696e71c43cf62de5f60fa72890203010001a000

algorithm (просто: sha256WithRSAEncryption):
06092a864886f70d01010b0500

signature (BIT STRING, так что игнорирую первый 0x00 байт):
30a1f3acf346b5c797892b85b7895acf49c5bedf29b0072384bfca7b765fff10ef2c8ba6d4b8bb436eaaa1102ab2df74f94c7e23ad7056aaf01d3a36944cd4f9

Для своих экспериментов я использую NodeJS и встроенную в него библиотеку crypto. Моя основная цель - научиться генерировать подпись самостоятельно, так что с этого и начну

const privateKey = fs.readFileSync('./private.key');
const sign = crypto.createSign('SHA256'); // 'sha256WithRSAEncryption' yields same result
sign.update(toBeSigned);
console.log(sign.sign(privateKey).toString('hex'));

// 257ea64e1e26ff56a13941aa8dc2f3920ce160ef8229d3096f71d41743f1072da7f6cf6471eab78fac73667ea349f73b34e022c2ed420c538a01d833656b018a
Почему так получилось (СПОЙЛЕР)

Ниже по тексту я обнаружу, что заголовок (30 72) обязателен. Если бы я сразу попробовал его добавить, и подписал с ним, то у меня бы получилась ровно та подпись, что и в CSR.

Не очень похоже на подпись, сгенерированную openssl. Попробуем немного другой способ:

const hash = crypto.createHash('SHA256');
hash.update(toBeSigned);
console.log(crypto.privateEncrypt(privateKey, hash.digest()).toString('hex'));

// 22f7dfb2d78a7d782526bdb5a209d8829ea15b21c694d6beaf057c4f267d4c10b15572cd63cfb86429b9b019216a1e214d05b62a90121f2e174cd5b2357dbe2c
Почему так получилось (СПОЙЛЕР)

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

Не совпадает ни с изначальной подписью, ни с полученной предыдущим способом. Решил с последним не разбираться, а ехать дальше. Попробуем провалидировать.

const publicKey = extractPublicKey(toBeSigned); // удоставерились, что совпадает с нашим public.key, упомянутым выше

const verify = crypto.createVerify('SHA256'); // 'sha256WithRSAEncryption' - тот же результат
verify.update(toBeSigned);
console.log(verify.verify(publicKey, signature));

// false

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

const hash = crypto.createHash('SHA256');
hash.update(toBeSigned);
console.log('HASH:', hash.digest().toString('hex'));
const decrypt = crypto.publicDecrypt(publicKey, signature);
console.log('DECRYPT:', decrypt.toString('hex'));

// HASH: 1dadbc87fb115c377a9c7055739b6bf71666cd3d6f13923406e321addef9f4a9
// DECRYPT: 3031300d060960864801650304020105000420d55d923fe33e4dcb1446bf8680763c49583c59d71a81adafdd611f4aa5fe2b10

Излишне говорить, что строки не совпадают. Более того, расшифрованная подпись существенно длиннее 256 бит, а её начало выглядит подозрительно структурированной. Отрежем все первые биты, превышающие свойственные SHA256 32 байта, чтобы посмотреть на них поближе.

30 31 30 0d 06 09 60 86 48 01 65 03 04 02 01 05 00 04 20

Изначально я подумал, что это нечто вроде соли, но раскодировав в последствии с десяток-другой всяких ключей и CSR-ов, и взглянув на это дело свежим взглядом, конечно, я уловил, что это тоже DER структура!

30 31 (SEQUENCE - 49)
  30 0d (SEQUENCE - 13)
    06 09 (OBJECT - 9)
      60 86 48 01 65 03 04 02 01 (sha256)
    05 00 (NULL - 0)
  04 20 (OCTET - 32)
    d5 5d 92 3f e3 3e 4d cb 14 46 bf 86 80 76 3c 49 58 3c 59 d7 1a 81 ad af dd 61 1f 4a a5 fe 2b 10

И вот он, наш хеш! Все 256 бит (32 байта). Неудивительно, что стандартные библиотечные утилиты не в состоянии провалидировать эту подпись, никто, думаю, не рассчитывает, что она будет так завёрнута. Вот только с нашим хешем она по-прежнему не совпадает...

Вернувшись к изначальным данным и внимательнее на них посмотрев, я увидел, что ошибка была на поверхности.

toBeSigned (все данные, исключая заголовок):
020100300e310c300a06035504030c03617364305c300d06092a864886f70d0101010500034b003048024100ad5189283bf84dacdadc82b63ffadc85e4f45f274f8e3e3c13b2a01f2b7f2ced914580ff38408d8cd53be0c797f71c700f7de8e696e71c43cf62de5f60fa72890203010001a000

А может заголовок-то всё же нужен? Добавляю в начало 3073, хэширую в SHA256 и - Вуаля!

hash.digest().toString('hex')
// 'd55d923fe33e4dcb1446bf8680763c49583c59d71a81adafdd611f4aa5fe2b10'

3. Как подписать-то в итоге?

Итог моих мук оказался следующим.

  1. Для того, чтобы подписать CSR, давайте сразу выделим ту часть, которую собираемся подписывать (вместе с заголовком (Тип 30 + длина)):

3073020100300e310c300a06035504030c03617364305c300d06092a864886f70d0101010500034b003048024100ad5189283bf84dacdadc82b63ffadc85e4f45f274f8e3e3c13b2a01f2b7f2ced914580ff38408d8cd53be0c797f71c700f7de8e696e71c43cf62de5f60fa72890203010001a000
Расшифровка DER
30 73 (SEQUENCE - 115)
  02 01 (INTEGER - 1) // Version
    00 (0)
  30 0e (SEQUENCE - 14) // Subject
    31 0c (SET - 12)
      30 0a (SEQUENCE - 10)
        06 03 (OBJECT - 3)
          55 04 03 (commonName)
        0c 03 (UTF8 - 3)
          61 73 64 (asd)
  30 5c (SEQUENCE - 92) // RSA Public Key
    30 0d (SEQUENCE - 13)
      06 09 (OBJECT - 9)
        2a 86 48 86 f7 0d 01 01 01 (rsaEncryption)
      05 00 (NULL - 0)
    03 4b (BIT - 75)
      00 30 48 02 41 00 ad 51 89 28 3b f8 4d ac da dc 82 b6 3f fa dc 85 e4 f4 5f 27 4f 8e 3e 3c 13 b2  ...
  a0 00 (BER - 0) // Extended attribute list 

  1. Выбираем тип хеширования, в нашем случае это SHA256, и загоняем туда наши данные.

d55d923fe33e4dcb1446bf8680763c49583c59d71a81adafdd611f4aa5fe2b10
  1. Создаём обёртку в DER формате с указанием хеширующего алгоритма

3031300d060960864801650304020105000420d55d923fe33e4dcb1446bf8680763c49583c59d71a81adafdd611f4aa5fe2b10
Расшифровка DER
 30 31 (SEQUENCE - 49)
  30 0d (SEQUENCE - 13)
    06 09 (OBJECT - 9)
      60 86 48 01 65 03 04 02 01 (sha256)
    05 00 (NULL - 0)
  04 20 (OCTET - 32)
    d5 5d 92 3f e3 3e 4d cb 14 46 bf 86 80 76 3c 49 58 3c 59 d7 1a 81 ad af dd 61 1f 4a a5 fe 2b 10

  1. Нашим приватным ключом шифруем полученную обёртку, получаем

30a1f3acf346b5c797892b85b7895acf49c5bedf29b0072384bfca7b765fff10ef2c8ba6d4b8bb436eaaa1102ab2df74f94c7e23ad7056aaf01d3a36944cd4f9
  1. Добавляем заголовок BIT STRING (учитываем особенности типа и не забываем добавить 00 в начало строки) и описание алгоритма подписи:

300d06092a864886f70d01010b0500
03410030a1f3acf346b5c797892b85b7895acf49c5bedf29b0072384bfca7b765fff10ef2c8ba6d4b8bb436eaaa1102ab2df74f94c7e23ad7056aaf01d3a36944cd4f9
Расшифровка DER
30 0d (SEQUENCE - 13)
  06 09 (OBJECT - 9)
    2a 86 48 86 f7 0d 01 01 0b (sha256WithRSAEncryption)
  05 00 (NULL - 0)
03 41 (BIT - 65)
  00 30 a1 f3 ac f3 46 b5 c7 97 89 2b 85 b7 89 5a cf 49 c5 be df 29 b0 07 23 84 bf ca 7b 76 5f ff  ... 

  1. Последним шагом кладём это всё на один уровень с нашим основным документом, заворачиваем в SEQUENCE с нужной длиной - и получаем ровно тот документ, который описан в части 1.3 этого текста.

4. Подведение итогов

Данную статью я решил написать, так как в процессе своих поисков сумел найти довольно мало полезной информации. Ту, что нашёл, и которая оказалась максимально полезной в изучении вопроса, привёл в виде ссылок в тексте, плюс приведу ещё несколько в конце. Однако встретил ещё немало сложностей, до решения которых пришлось с трудами докапываться самостоятельно. Надеюсь, что если кто-то задастся подобной задачей, поиски приведут его сюда, и у него процесс займёт существенно меньше времени.

Это моя первая статья, которую я осмелился отправить на Хабр. Очень надеюсь, что её сочтут познавательной и/или интересной.

4.1. Ещё несколько ссылок

https://www.rfc-editor.org/rfc/rfc2986 - документация по CSR синтаксису
https://oidref.com/ - база данных по OID
https://learn.microsoft.com/en-us/windows/win32/seccertenroll/about-der-encoding-of-asn-1-types - хорошее описание типов ASN.1 в DER структуре
https://luca.ntop.org/Teaching/Appunti/asn1.html - более подробное описание DER структуры, но к некоторым местам у меня остались вопросы
https://mbed-tls.readthedocs.io/en/latest/kb/cryptography/asn1-key-structures-in-der-and-pem/ - подробное описание структуры приватного и публичного ключей
https://www.geeksforgeeks.org/rsa-algorithm-cryptography/ - алгоритм генерации ключей шифрование, если хочется понять, что все эти странные огромные числа в ключах означают