Перейти к содержимому
ecdsa.com
← Все статьи

Форматы подписей ECDSA: DER или raw (P1363), high-S и подводные камни разных библиотек

Ключ верный, сообщение верное, алгоритм верный — а проверка всё равно не проходит. Чаще всего две стороны просто по-разному понимают, как расположены байты подписи. Здесь байт за байтом разобраны две кодировки подписи ECDSA и тонкость с high-S, которая ломает совместимость даже при совпадающей кодировке.

Подпись — это два целых числа

Какие бы байты вы ни видели при передаче, математически подпись 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 с комментариями:

Подпись ECDSA в кодировке DER с комментариями
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 / secp256k132 байта64 байта
P-38448 байт96 байт
P-52166 байт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:cryptoDERПараметр dsaEncoding: "ieee-p1363" в sign/verify (доступен начиная с ветки Node 12 LTS)
WebCrypto crypto.subtleТолько raw (P1363)DER не поддерживается — преобразуйте до или после вызова
Go crypto/ecdsaНи тот, ни другой: Sign/Verify принимают r, s как *big.IntSignASN1 / 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 может выдать любую из двух кодировок с одним и тем же ключом:

node: один ключ, оба формата
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-подписи фиксированной ширины, дополненные нулями:

go: на выходе DER и 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 и предоставляет вспомогательные функции, чтобы добраться до целых чисел:

python: из DER в raw с помощью pyca/cryptography
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», на которую ушло полдня, обычно оказывается действительной с самого начала.