ECDSA signatureValue всередині контейнера CMS — це DER SEQUENCE { INTEGER r, INTEGER s }. Функція Windows CNG BCryptVerifySignature не приймає жодного з них: вона хоче IEEE P1363 з фіксованою шириною r || s, без тегів і без довжин. HotPDF, нативний VCL-компонент PDF для Delphi та C++Builder, перетворює між ними за строгими правилами DER перед імпортом ключа
Збій, якому це запобігає, — специфічний і деморалізуючий. Acrobat відкриває документ і показує зелену галочку. Твій власний верифікатор, обходячи ті самі байти, повертає невалідний результат, або CNG повертає STATUS_INVALID_SIGNATURE без жодних пояснень. З підписом усе гаразд. Не так приблизно сімдесят байтів ASN.1 передали в API, що очікував шістдесят чотири байти сирого цілого числа, і невідповідність невидима, якщо не знаєш, що її треба шукати
Чому BCryptVerifySignature відхиляє валідний ECDSA-підпис?
Тому що обидві сторони виклику говорять різними кодуваннями підпису, і жодна з них про це не повідомляє. ISO 32000-1 §12.8 каже, що словник підпису несе блоб CMS у /Contents; RFC 5652 §5.3 каже, що signatureValue в кожному SignerInfo — це OCTET STRING, чий вміст залежить від того, що визначає алгоритм підпису. Для ECDSA цей вміст — структура SEC 1 DER: SEQUENCE, що тримає два INTEGER. Вона має змінну довжину за задумом, бо r і s — цілі числа, а DER відкидає провідні нульові октети у цілих числах
IEEE P1363 має протилежний погляд. Він визначає підпис як конкатенацію двох координат, кожна з яких доповнена зліва нулями рівно до ширини в байтах поля кривої. Підпис P-256 завжди 64 байти. DER-кодування того самого підпису зазвичай 70 чи 71 байт і може бути будь-де від приблизно 8 до 72. Передай форму DER у BCryptVerifySignature, і сама лише перевірка довжини прирікає виклик, тому HotPDF нормалізує до перевірки, а не після неї
uses
HPDFECDSA;
// Converts a CMS signatureValue into the fixed-width form CNG expects.
// ARaw comes back as 64 bytes for P-256, 96 for P-384, 132 for P-521.
function ToP1363(const ADerSig: TBytes; ACurve: THPDFECDSACurve;
out ARaw: TBytes): Boolean;
begin
Result := HPDFECDSANormalizeSignature(ADerSig, ACurve, eseDER, ARaw);
end;
Правила DER, які парсер підписів не має права послаблювати
Кожна відмова, перелічена тут, — це відмова, яку HotPDF виконує навмисно, і кожна закриває шлях, який поблажливий парсер лишив би відкритим. Спокуса при написанні конвертера — знайти два вузли INTEGER, скопіювати їхній вміст і рухатися далі. Це працює на добре сформованому вводі й тихо приймає цілу родину малюючих переформатувань на ворожому вводі. Тож HPDFECDSANormalizeSignature відхиляє від'ємне ціле число, тобто будь-яке r чи s, чий перший байт вмісту має встановлений старший біт, бо валідний скаляр ECDSA завжди додатний. Він відхиляє значення, що повністю дорівнює нулю, оскільки r = 0 чи s = 0 ніколи не є легітимним підписом. Він відхиляє надлишковий провідний нульовий октет: X.690 §8.3 дозволяє рівно один, і лише тоді, коли наступний октет інакше читався б як від'ємний, тож 00, за яким йде октет менше 0x80, — це переформатування, а не підпис. Він відхиляє нетривіальний заголовок довжини, бо X.690 §10.1 вимагає визначену форму, закодовану якнайменшою кількістю октетів, а довга форма довжини, яка могла б бути короткою, — інший рядок байтів з тим самим значенням. Він відхиляє ціле число, ширше за розмір координати кривої, оскільки таке значення не може бути елементом поля. І він відхиляє будь-який вузол після s, а також зовнішній SEQUENCE, чия загальна довжина не дорівнює довжині всього блобу
Ці останні два мають більше значення, ніж здається. Байти, що йдуть після SEQUENCE, — класичний трюк малюваності підпису: додай сміття, і поблажливий верифікатор все одно скаже "валідний", тоді як рядок байтів, який він перевірив, не є тим рядком байтів, що був підписаний. Той самий інстинкт стоїть за посиленням довжин ASN.1, описаним у нотатці про парсинг PKCS#12, і той самий інстинкт тут. У шляху перевірки прийнята структура, яку жоден сумісний підписант ніколи не видав би, — це дефект, а не люб'язність
Ширина координати належить кривій, а не підпису
HotPDF виводить вихідну ширину з іменованого OID кривої, ніколи з довжини DER, який щойно розпарсив. Це друга половина перетворення, і саме та половина, яку легко тонко зіпсувати. RFC 5480 §2.1.1 ідентифікує криву в параметрах SubjectPublicKeyInfo сертифіката, а HPDFECDSACurveFromOID відображає три OID, що їх підтримує HotPDF: 1.2.840.10045.3.1.7 для P-256, 1.3.132.0.34 для P-384 і 1.3.132.0.35 для P-521. HPDFECDSACoordinateSize потім повертає 32, 48 або 66 байтів, а буфер P1363 — удвічі більший: 64, 96 чи 132. Кожне декодоване ціле число вирівнюється по правому краю у своїй половині, тож короткий r доповнюється нулями зліва, а не зсувається. P-521 — той, що ловить людей, бо 521 біт — це 65,125 байта, і округлюється до 66, даючи 132-байтний підпис, якого жодна інтуїція про степені двійки не передбачила б. Публічний ключ подорожує поруч як нестиснена точка EC за RFC 5480 §2.2, тобто 0x04, за яким ідуть X та Y, тож HotPDF перевіряє, що він точно 1 + 2 * CoordinateSize байтів і починається з 0x04, перш ніж торкатися CNG
var
Digest, SigDER, PublicPoint: TBytes;
Curve: THPDFECDSACurve;
Res: THPDFECDSAVerifyResult;
begin
// secp256r1, taken from the certificate SubjectPublicKeyInfo parameters
Curve := HPDFECDSACurveFromOID('1.2.840.10045.3.1.7');
// PublicPoint must be $04 || X || Y, so 1 + 2 * 32 = 65 bytes for P-256
Res := HPDFECDSAVerifyDigest(Digest, SigDER, PublicPoint, Curve, eseDER);
case Res of
evrValid:
Memo1.Lines.Add('signature verifies');
evrInvalid:
Memo1.Lines.Add('signature does not match the digest');
evrMalformed:
Memo1.Lines.Add('DER encoding or public point rejected');
evrUnsupported:
Memo1.Lines.Add('curve or algorithm not supported here');
evrProviderUnavailable:
Memo1.Lines.Add('bcrypt.dll or the curve provider is missing');
evrProviderError:
Memo1.Lines.Add('CNG returned an unexpected status');
end;
end;
Зверни увагу на останній параметр. HPDFECDSAVerifyDigest також приймає eseP1363 для викликачів, які вже тримають підпис фіксованої ширини, з апаратного токена чи віддаленого сервісу підписання, що повертає сирий r || s. Цей шлях все одно примусово перевіряє довжину і ненульовість обох половин, тож буфер правильного розміру, повний нулів, відхиляється, а не передається провайдеру як є
Чому загальна назва алгоритму ECDSA не працює на старіших Windows?
Тому що загальна назва новіша за базу розгортання, під яку ти постачаєш. CNG надає ідентифікатор алгоритму ECDSA, що виводить криву з імпортованого ключа, і це чистий спосіб написати цей код, але BCryptOpenAlgorithmProvider гарантовано розв'язує його лише на новіших версіях Windows. На старішій машині виклик відкриття провалюється, дескриптор провайдера лишається нульовим, і кожна перевірка ECDSA у твоєму застосунку повідомляє "не підтримується" на підписі, що є цілком добрим. HotPDF уникає цього обриву, відкриваючи натомість ідентифікатори для кожної кривої окремо. Він розв'язує ECDSA_P256, ECDSA_P384 та ECDSA_P521 один раз, кешує один дескриптор провайдера на криву і закриває їх при фіналізації модуля. Кожна подальша перевірка тоді робить лише дешеву роботу: імпортувати тимчасовий публічний ключ з ECCPUBLICBLOB, викликати BCryptVerifySignature, знищити ключ. Жодних повторних LoadLibrary, жодних повторних GetProcAddress, жодного відкриття й закриття провайдера на кожен підпис. Пакетна перевірка кількох сотень документів відчуває цю різницю, так само як і сервісний процес, що інакше перемелював би дескриптори провайдера під навантаженням
Коди результату лишаються чесними щодо цієї різниці. evrProviderUnavailable означає, що машина не змогла дати HotPDF провайдера; evrInvalid означає, що CNG відповів STATUS_INVALID_SIGNATURE. Злиття цих двох в один збій — саме так проблема розгортання помилково звітується як підроблений документ. Той самий поділ між збоєм середовища та криптографічним збоєм проходить крізь обробку CNG та CAPI на боці підписання, розглянуту в статті про підписання зі сховища сертифікатів та порядок байтів
Хто підписав цей документ? SignerIdentifier — це дві різні речі
RFC 5652 §5.3 робить SignerIdentifier вибором CHOICE, і верифікатор, що обробляє лише одну гілку, тихо перевірить проти неправильного ключа. Перша гілка — issuerAndSerialNumber, SEQUENCE, що тримає ім'я видавця в сирому DER та ціле число серійного номера, і зіставлення його — це побайтове порівняння з кожним сертифікатом у наборі certificates CMS. Друга гілка — [0] subjectKeyIdentifier, неявно тегований OCTET STRING, і зіставлення його вимагає копання в сертифікат, а не порівняння його полів заголовка
Копання має шар, що дивує людей. Ідентифікатор ключа живе в розширенні X.509v3, тож HotPDF обходить поле розширень [3] tbsCertificate, знаходить розширення, чий OID — 2.5.29.14, пропускає опційний критичний BOOLEAN і бере OCTET STRING extnValue. Цей рядок октетів — не ідентифікатор. За RFC 5280 §4.2.1.2 його вміст сам є DER, а тип KeyIdentifier — інший OCTET STRING, тож потрібно парсити вдруге, щоб дістатися фактичних байтів. Зупинися на шар раніше, і ти порівнюєш 22-байтну обгортку з 20-байтним ідентифікатором, жоден сертифікат ніколи не збігається, а верифікатор відкочується до якоїсь евристики, яку ти написав далі, а це і є справжня небезпека. Взяти перший сертифікат у наборі — приваблива короткозорість, і вона хибна щоразу, коли CMS несе ланцюжок, тобто здебільшого, бо лист не зобов'язаний іти першим. HotPDF приймає незіставлений сертифікат, лише коли контейнер тримає рівно один; за наявності кількох сертифікатів точний збіг SignerIdentifier обов'язковий. Перевірка дайджесту проти публічного ключа проміжного CA не дає дружньої помилки, вона дає впевнено невалідний результат на документі, з яким усе гаразд
var
Pdf: THotPDF;
Info: THPDFSignatureInfo;
I: Integer;
begin
Pdf := THotPDF.Create(nil);
try
if Pdf.LoadFromFile('signed.pdf') > 0 then
for I := 0 to Pdf.GetLoadedSignatureFieldCount - 1 do
if Pdf.VerifyLoadedSignatureEx(I, Info) = svValid then
Memo1.Lines.Add(Format('%s: %s %s over %s, signer %s',
[String(Info.FieldName), String(Info.PublicKeyAlgorithm),
String(Info.CurveName), String(Info.HashAlgorithm),
String(Info.SignerName)]))
else
Memo1.Lines.Add(Format('%s: not valid', [String(Info.FieldName)]));
finally
Pdf.Free;
end;
end;
THPDFSignatureInfo.CurveName повідомляє P-256, P-384 чи P-521, тож журнал аудиту фіксує, яка крива фактично використана, а не просто слово ECDSA. Документо-рівнева обв'язка навколо цього виклику, зокрема як хешуються сегменти /ByteRange і чому дайджест має обчислюватися над файлом, а не над розпарсеним деревом об'єктів, — це тема супровідної статті про перевірку цифрових підписів PDF
Чого це не дає
Зелений результат від HPDFECDSAVerifyDigest відповідає лише на одне питання: ці байти підписано приватним ключем, що відповідає цьому публічному ключу. Він нічого не каже про те, чи належить цей ключ комусь, кому варто довіряти. Побудова ланцюжка до кореня довіри, відкликання через CRL чи OCSP та перевірки політики — окрема робота, і будь-який продукт, що звітує про валідний підпис без них, звітує про менше, ніж припускає користувач. Дати чинності сертифіката виводяться окремо в THPDFSignatureInfo саме з цієї причини: підпис може криптографічно перевірятися, тоді як сертифікат, що його зробив, спливло два роки тому. Підтримка кривих також свідомо вузька. Три криві NIST prime обробляються, а підпис над будь-якою іншою кривою повертає "не підтримується", а не здогадку. Шлях CNG доступний лише на Windows, що є правильним компромісом для VCL-компонента, але про це варто знати, перш ніж планувати навколо нього крос-платформний сервіс. І строгість не конфігурується: немає поблажливого режиму, що приймав би нетривіальну довжину DER лише тому, що якийсь застарілий підписант її видав. Якщо зустрічаєш такий файл у продакшені, чесна відповідь — зафіксувати його і розібратися з виробником, а не розширювати парсер, доки файл не пройде
Описаний тут шлях перевірки ECDSA постачається як частина стандартного HotPDF Component для Delphi та C++Builder, поряд з шляхами RSA PKCS#1 v1.5 та RSA-PSS і повним записом інформації про підпис; сторінка продукту містить повний довідник цифрового підпису