signatureValue מסוג ECDSA בתוך מכולת CMS הוא SEQUENCE { INTEGER r, INTEGER s } בפורמט DER. הפונקציה BCryptVerifySignature של Windows CNG לא מקבלת אף אחד מהם: היא רוצה r || s ברוחב קבוע לפי IEEE P1363, ללא תגיות וללא אורכים. HotPDF, רכיב ה-VCL הילידי ל-PDF עבור Delphi ו-C++Builder, ממיר בין השניים לפי כללי DER קפדניים לפני ייבוא מפתח
הכשל שזה מונע הוא ספציפי ומתסכל. Acrobat פותח את המסמך ומראה סימן ירוק. המאמת שלכם, שעובר על אותם בייטים, מחזיר לא תקין, או ש-CNG מחזיר STATUS_INVALID_SIGNATURE ללא הסבר נוסף. שום דבר לא לא בסדר עם החתימה. מה שלא בסדר הוא שכ-שבעים בייטים של ASN.1 הועברו ל-API שציפה לשישים וארבעה בייטים של מספר שלם גולמי, וחוסר ההתאמה בלתי נראה אלא אם יודעים לחפש אותו
מדוע BCryptVerifySignature דוחה חתימת ECDSA תקינה?
כי שני צדי הקריאה מדברים קידודי חתימה שונים, ואף אחד מהם לא מכריז על כך. ISO 32000-1 §12.8 אומר שמילון חתימה נושא blob של CMS ב-/Contents; RFC 5652 §5.3 אומר שה-signatureValue בכל SignerInfo הוא OCTET STRING שהתוכן שלו הוא מה שאלגוריתם החתימה מגדיר. עבור ECDSA התוכן הזה הוא מבנה ה-DER של SEC 1: 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, להעתיק את תוכנם, ולהמשיך הלאה. זה עובד על קלט תקין ומקבל בשקט משפחה של קידודים-מחדש בני-שינוי (malleable) על קלט עוין. אז HPDFECDSANormalizeSignature דוחה מספר שלם שלילי, כלומר כל r או s שהאוקטט הראשון שלהם מכיל את הביט הגבוה מוגדר, כי סקלר ECDSA תקין הוא חיובי. הוא דוחה ערך שהוא כולו אפס, מכיוון ש-r = 0 או s = 0 לעולם אינו חתימה לגיטימית. הוא דוחה אוקטט אפס מוביל מיותר: X.690 §8.3 מתיר בדיוק אחד, ורק כאשר האוקטט הבא היה אחרת נקרא כשלילי, כך ש-00 ואחריו אוקטט מתחת ל-0x80 הוא קידוד-מחדש, לא חתימה. הוא דוחה כותרת אורך שאינה מינימלית, כי X.690 §10.1 דורש את הצורה המוגדרת מקודדת במספר האוקטטים המינימלי, וצורת אורך ארוכה שיכלה להיות קצרה היא מחרוזת בייטים שונה הנושאת את אותה משמעות. הוא דוחה מספר שלם רחב יותר מגודל הקואורדינטה של העקומה, מכיוון שערך כזה לא יכול להיות איבר שדה. והוא דוחה כל צומת נגרר אחרי s, יחד עם SEQUENCE חיצוני שאורכו הכולל לא שווה לאורך כל ה-blob
שני האחרונים חשובים יותר ממה שהם נראים. בייטים נגררים אחרי ה-SEQUENCE הם תרגיל ה-signature-malleability הקלאסי: מוסיפים זבל, ומאמת סלחני עדיין אומר תקין בעוד מחרוזת הבייטים שהוא אימת אינה מחרוזת הבייטים שנחתמה. אותה אינסטינקט מניע את חיזוק אורך ה-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 בייט, ו-buffer ה-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 גולמי. הנתיב הזה עדיין אוכף את בדיקת האורך ובדיקת אי-האפסיות על שתי המחציות, כך ש-buffer בגודל הנכון המלא באפסים נדחה במקום להיות מועבר לספק
מדוע שם האלגוריתם הגנרי של ECDSA נכשל ב-Windows ישן יותר?
כי השם הגנרי חדש יותר מבסיס הפריסה שאתם משגרים אליו. CNG חושף מזהה אלגוריתם ECDSA שמסיק את העקומה מהמפתח המיובא, וזו הדרך הנקייה לכתוב את הקוד הזה, אך BCryptOpenAlgorithmProvider מובטח לפתור אותו רק בגרסאות Windows חדשות יותר. במחשב ישן יותר קריאת הפתיחה נכשלת, כידית הספק נשארת nil, וכל אימות ECDSA באפליקציה שלכם מדווח לא נתמך על חתימה שהיא לגמרי תקינה. HotPDF נמנע מהצוק על ידי פתיחת המזהים לפי-עקומה במקום זאת. הוא פותר ECDSA_P256, ECDSA_P384, ו-ECDSA_P521 פעם אחת, שומר במטמון כידית ספק אחת לכל עקומה, וסוגר אותן ב-finalization של היחידה (unit). כל אימות אז עושה רק את העבודה הזולה: ייבוא מפתח ציבורי זמני מ-ECCPUBLICBLOB, קריאה ל-BCryptVerifySignature, השמדת המפתח. ללא LoadLibrary חוזר, ללא GetProcAddress חוזר, ללא פתיחה וסגירה של ספק לכל חתימה. אימות אצווה של כמה מאות מסמכים מרגיש את ההבדל, וכך גם תהליך שירות שאחרת היה מכלה כידיות ספק תחת עומס
קודי התוצאה נשארים ישרים לגבי ההבחנה. evrProviderUnavailable אומר שהמחשב לא יכול לתת ל-HotPDF ספק; evrInvalid אומר ש-CNG ענה STATUS_INVALID_SIGNATURE. קריסת השניים לכשל אחד היא הדרך שבה בעיית פריסה מדווחת בטעות כמסמך מזויף. אותה הפרדה בין כשל סביבה לכשל קריפטוגרפי עוברת דרך הטיפול ב-CNG וב-CAPI בצד החתימה, המכוסה בהמאמר על חתימת מאגר תעודות וסדר בייטים
מי חתם על זה? SignerIdentifier הוא שני דברים שונים
RFC 5652 §5.3 הופך את SignerIdentifier ל-CHOICE, ומאמת שמטפל בזרוע אחת בלבד יאמת בשקט מול המפתח הלא נכון. הזרוע הראשונה היא issuerAndSerialNumber, SEQUENCE המחזיק את שם ה-issuer ב-DER גולמי ואת ה-serial INTEGER, והתאמתה היא השוואת בייטים מול כל תעודה בקבוצת 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 נוסף, כך שמנתחים פעם שנייה כדי להגיע לבייטים בפועל. עוצרים שכבה אחת מוקדם מדי ומשווים wrapper בן 22 בייט מול מזהה בן 20 בייט, אף תעודה לעולם לא תואמת, והמאמת נופל חזרה על איזו היוריסטיקה שכתבתם בהמשך, וזו הסכנה האמיתית. לקיחת התעודה הראשונה בקבוצה היא קיצור דרך מפתה והוא שגוי בכל פעם שה-CMS נושא שרשרת, מה שקורה רוב הזמן, כי העלה (leaf) אינו נדרש להגיע ראשון. HotPDF מקבל תעודה שלא הותאמה רק כאשר המכולה מחזיקה בדיוק אחת; עם תעודות מרובות נוכחות, התאמת SignerIdentifier מדויקת היא חובה. אימות תקציר (digest) מול מפתח ציבורי של 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. האינסטלציה (plumbing) ברמת המסמך סביב הקריאה הזו, בפרט כיצד קטעי /ByteRange מגובבים (hashed) ומדוע התקציר חייב להיות מחושב על פני הקובץ ולא על עץ האובייקטים המנותח, הוא נושא המאמר הנלווה על אימות חתימות דיגיטליות ב-PDF
מה זה לא נותן לכם
תוצאה ירוקה מ-HPDFECDSAVerifyDigest עונה על שאלה אחת בלבד: הבייטים הללו נחתמו על ידי המפתח הפרטי שתואם למפתח הציבורי הזה. זה לא אומר דבר על כך שהמפתח הזה שייך למישהו שאתם אמורים לסמוך עליו. בניית שרשרת לעוגן אמון, ביטול (revocation) דרך CRL או OCSP, ובדיקות מדיניות הן עבודה נפרדת, וכל מוצר שמדווח חתימה תקינה בלעדיהן מדווח פחות ממה שהמשתמש מניח. תאריכי תוקף התעודה מוצגים בנפרד ב-THPDFSignatureInfo בדיוק מהסיבה הזו: חתימה יכולה להיות מאומתת קריפטוגרפית בעוד התעודה שיצרה אותה פגה תוקפה לפני שנתיים. תמיכת העקומות גם היא מוגבלת בכוונה. שלוש עקומות ראשוניות NIST מטופלות, וחתימה על פני כל עקומה אחרת מחזירה לא נתמך במקום ניחוש. נתיב ה-CNG הוא Windows-בלבד, שזה העסקה הנכונה עבור רכיב VCL אך כדאי לציין אותה לפני שמתכננים שירות חוצה-פלטפורמות סביבו. והקפדנות אינה ניתנת להגדרה: אין מצב סלחני שמקבל אורך DER לא-מינימלי כי חותם ישן כלשהו הפיק אחד כזה. אם נתקלים בקובץ כזה בייצור, התגובה הכנה היא לתעד אותו ולרדוף אחרי היצרן, לא להרחיב את המנתח עד שהקובץ עובר
נתיב אימות ה-ECDSA המתואר כאן מגיע כחלק מהגרסה הסטנדרטית של HotPDF Component עבור Delphi ו-C++Builder, לצד נתיבי RSA PKCS#1 v1.5 ו-RSA-PSS ורשומת מידע החתימה המלאה; דף המוצר נושא את הפניית החתימה הדיגיטלית המלאה