Подпись — это два целых числа
Какие бы байты вы ни видели при передаче, математически подпись ECDSA — это просто пара положительных целых чисел (r, s), каждое от 1 до n − 1, где n — порядок кривой (для P-256 и secp256k1 это число, близкое к 2²⁵⁶). Всё остальное — сериализация, и в экосистеме закрепились два несовместимых способа её выполнить. Ни один из них не содержит информации, которой нет в другом; преобразование между ними выполняется без потерь и чисто механически.
Формат 1: ASN.1 DER — наследие X.509
Формат, который используют OpenSSL, сертификаты X.509 и TLS, пришёл из мира ASN.1: пара кодируется как DER-структура SEQUENCE из двух INTEGER. Пример для P-256 с комментариями:
30 45 SEQUENCE, 0x45 = 69 bytes follow
02 21 INTEGER, 0x21 = 33 bytes (this is r)
00 c8 7f 3b ... 33 bytes: leading 00 because r's top bit is set
02 20 INTEGER, 0x20 = 32 bytes (this is s)
6a 12 09 ... 32 bytes: no padding needed, top bit is clear
Немного коварным этот формат делают правила DER для целых чисел:
- Целые числа записываются в порядке big-endian, в дополнительном коде и имеют минимальную длину: ведущие нулевые байты обязательно отбрасываются.
- Но значения положительны, поэтому, если старший бит первого байта установлен (первый байт ≥
0x80), в начало обязательно добавляется один байт0x00, чтобы число не читалось как отрицательное. Уrиsэтот байт дополнения может быть или не быть независимо друг от друга. - В итоге получается кодировка переменной длины: для P-256 обычно 70, 71 или 72 байта (а изредка и короче, если
rилиsслучайно начинается с нулевых байтов). Код, который рассчитывает на фиксированную длину подписи, — это ошибка, которая ждёт неудачного случайного значения. - DER — отличительная (distinguished) кодировка: каждому значению соответствует ровно одна допустимая строка байтов. Строгие парсеры отвергают неминимальные целые числа, дополнение не по спецификации и лишние байты в конце; Bitcoin сделал строгий DER правилом консенсуса (BIP-66) именно для того, чтобы устранить расхождения между парсерами.
Формат 2: raw r‖s — IEEE P1363, формат фиксированной длины
Второе соглашение полностью обходится без ASN.1: r и s дополняют каждое ровно до размера координаты кривой (big-endian) и склеивают. Этот формат часто называют форматом IEEE P1363 (по стандарту, в котором он использовался), «raw» или «плоским» r‖s:
| Кривая | Размер элемента поля | Размер raw-подписи |
|---|---|---|
| P-256 / secp256k1 | 32 байта | 64 байта |
| P-384 | 48 байт | 96 байт |
| P-521 | 66 байт | 132 байта |
Фиксированная длина, никаких тегов, никаких правил дополнения: разрежьте блок пополам — и вот ваши числа. Именно этот формат выдаёт WebCrypto, его же RFC 7518 § 3.4 предписывает для JWS (а значит, и для JWT; практические последствия разобраны в статье ES256 или RS256 для JWT), и именно с таким представлением внутри работает большинство блокчейн-стеков: подписи Ethereum — это 65-байтные r‖s‖v (пара в формате raw плюс байт восстановления), тогда как Bitcoin, как отмечено выше, кодирует подписи транзакций в строгом DER.
Сбой симметричен и встречается очень часто: 64-байтная raw-подпись, переданная API, который ждёт DER, падает уже в парсере ASN.1 (ожидается 0x30), а DER-блок длиной около 71 байта, переданный WebCrypto или коду проверки JWS, просто имеет неверную длину и без лишнего шума отклоняется с общим сообщением «invalid signature». Когда два стека расходятся, преобразуйте подпись явно: конвертер DER ⇄ raw делает это в обе стороны прямо в браузере и показывает декодированные r и s.
Подписи с high-S и low-S: почему действительную подпись отклоняют
Даже при совпадающих кодировках проверку между разными стеками ломает ещё одна тонкость. У ECDSA есть встроенная симметрия: если (r, s) — действительная подпись, то (r, n − s) тоже действительная подпись того же сообщения тем же ключом. Любой, у кого есть подпись, может перевести её в другую форму без закрытого ключа: байты подписи пластичны (malleable), хотя подписанное содержимое изменить нельзя.
Для протоколов, которые идентифицируют данные по хешу байтов подписей (классический пример — идентификаторы транзакций Bitcoin), такая пластичность, доступная третьим лицам, создаёт реальную проблему. Решение — каноническая форма low-S: требовать s ≤ n/2, а в противном случае нормализовать подпись, заменяя s на n − s. Bitcoin сделал low-S правилом ретрансляции (его предложили в BIP-62 и закрепили в BIP-146), а функция проверки в libsecp256k1 принимает только подписи с low-S, если вызывающий код не нормализовал подпись заранее. Некоторые библиотеки других экосистем, ориентированные на безопасность, тоже по умолчанию создают подписи с low-S, и их можно настроить так, чтобы при проверке они отклоняли high-S.
Кто что использует: форматы по умолчанию в библиотеках
| Библиотека / API | Формат по умолчанию | Другой формат |
|---|---|---|
| OpenSSL (CLI и EVP API) | DER | Встроенного режима raw для ECDSA нет; преобразуйте внешними средствами или соберите из r и s структуры ECDSA_SIG |
Node.js node:crypto | DER | Параметр dsaEncoding: "ieee-p1363" в sign/verify (доступен начиная с ветки Node 12 LTS) |
WebCrypto crypto.subtle | Только raw (P1363) | DER не поддерживается — преобразуйте до или после вызова |
Go crypto/ecdsa | Ни тот, ни другой: Sign/Verify принимают r, s как *big.Int | SignASN1 / VerifyASN1 для DER (Go 1.15+); raw собирается с помощью FillBytes |
Python cryptography (pyca) | DER | Вспомогательные функции decode_dss_signature / encode_dss_signature дают доступ к r и s |
Пакет Python ecdsa | По умолчанию raw | Аргументы sigencode_der / sigdecode_der |
| Java (JCA Signature) | DER (SHA256withECDSA) | SHA256withECDSAinP1363Format в современных JDK (9+) |
Несколько примеров в коде. Node.js может выдать любую из двух кодировок с одним и тем же ключом:
import { generateKeyPairSync, sign } from "node:crypto";
const { privateKey } = generateKeyPairSync("ec", { namedCurve: "P-256" });
const msg = Buffer.from("same signature, two encodings");
const der = sign("sha256", msg, privateKey);
const raw = sign("sha256", msg, { key: privateKey, dsaEncoding: "ieee-p1363" });
console.log(der.length); // 70..72 (varies per signature)
console.log(raw.length); // always 64
Go явно показывает лежащие в основе целые числа, поэтому и преобразование очевидно; обратите внимание на FillBytes, который даёт половины raw-подписи фиксированной ширины, дополненные нулями:
der, err := ecdsa.SignASN1(rand.Reader, priv, digest) // ASN.1 DER
if err != nil { return err }
r, s, err := ecdsa.Sign(rand.Reader, priv, digest) // the integers themselves
if err != nil { return err }
raw := make([]byte, 64)
r.FillBytes(raw[:32]) // zero-pads on the left — critical for short values
s.FillBytes(raw[32:])
Библиотека pyca cryptography для Python подписывает в DER и предоставляет вспомогательные функции, чтобы добраться до целых чисел:
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.asymmetric import ec
from cryptography.hazmat.primitives.asymmetric.utils import decode_dss_signature
der_sig = private_key.sign(message, ec.ECDSA(hashes.SHA256())) # DER bytes
r, s = decode_dss_signature(der_sig)
raw_sig = r.to_bytes(32, "big") + s.to_bytes(32, "big") # 64-byte P1363
Чек-лист для отладки
Если подпись ECDSA, созданная в одной системе, не проходит проверку в другой, пройдите по этому списку по порядку:
- Проверка длины. Ровно 64/96/132 байта → raw. Начинается с
0x30и содержит правдоподобный байт длины → DER. Всё остальное: неверное декодирование внешнего слоя (base64, hex) или данные, обрезанные при копировании. - Совпадение формата. Ожидает ли проверяющий API то, что выдал подписант? Преобразуйте подпись конвертером DER ⇄ raw, а не подписывайте заново.
- Дополнение нулями. Если raw-подписи время от времени не проходят проверку (примерно в 1 случае из 128: каждое из чисел
rиsначинается с нулевого байта примерно в 1 случае из 256), подозревайте преобразование, которое отбросило ведущие нулевые байты, вместо того чтобы дополнитьrиsслева до фиксированной ширины. - Значения high-S. Если сбои происходят примерно в 50 % случаев и на одной из сторон блокчейн или стек с усиленными требованиями к безопасности, сравните
sсn/2и нормализуйте подпись. - Всё остальное (неверный хеш, неверная кривая, хеширование хеша) относится к уровню алгоритма, а не формата; основы разобраны в статье ECDSA или RSA, с неё и начните.
Утешительный итог: DER или raw — всего лишь вопрос упаковки. В преобразовании не участвует никакая криптография, ключ никак не меняется, и как только обе стороны договорятся о расположении байтов (и о low-S там, где это важно), подпись с ошибкой «invalid signature», на которую ушло полдня, обычно оказывается действительной с самого начала.