רכיב PDFium מאתר את תחילתו של מבנה CMS מקונן לפי אורך התוכן שלו, ואף פעם לא בהליכה אחורה מעל אוקטטי האורך, כי הבית שנמצא ממש לפני התוכן הוא אוקטט האורך האחרון והוא לא אומר דבר על כמה קודמים לו. CmsHeaderStart ב-FPdfCms.pas גוזר את אורך הכותרת מ-ContentLen במקום זאת, וזה מה ש-DER עושה למדויק, וזה מה שמונע מ-AddSignatureTimestampToCms להשחית כל CMS שסט התעודות שלו ארוך מ-127 בתים
ההגדרה היא שדרוג PAdES B-T. מאפיין חתימת זמן, זה ש-ETSI EN 319 122-1 סעיף 5.3 מגדיר תחת ה-OID 1.2.840.113549.1.9.16.2.14, חייב לנחות ב-unsignedAttrs של ה-SignerInfo המתואר ב-RFC 5652 סעיף 5.3, ומעצם הגדרתו הוא יכול להתווסף רק אחרי שערך החתימה קיים, כי אסימון חתימת הזמן מחושב מעל אותו ערך. אז ה-CMS כבר בנוי וכבר חתום כשהאסימון מגיע. הוספת מאפיין אחד משנה את האורך של ה-SignerInfo, מה שמשנה את האורך של ה-SET signerInfos, ואז של SignedData, ואז של עוטף ה-EXPLICIT [0], ואז של ה-ContentInfo החיצוני. כל כותרת עוטפת חייבת להיפלט מחדש, וכל מה שלא נמצא במסלול הזה חייב לעבור כמו שהוא, בית-אחר-בית. המדריך על B-LT ו-B-LTA מכסה מה האסימון קונה לכם; המאמר הזה עוסק בארבעת הבתים שלפני סט התעודות שהבנייה מחדש כל הזמן שגתה בהם
למה הוספת חתימת זמן צריכה את היסט התג של אח?
כי הבנייה מחדש עושה שימוש חוזר מילולי בארבעה אחים של ה-SET signerInfos, והקורא מדווח איפה התוכן שלהם נמצא, לא איפה התג שלהם נמצא. TDerReader.ReadTlv מחזיר את בית התג, את היסט התוכן, את אורך התוכן ואת ההיסט של ה-TLV הבא. זה המשטח הנכון לרדת לתוך מבנה, אבל כדי להעתיק אלמנט שלם צריך את הבית שבו התג שלו יושב, והדבר היחיד שהקורא מחזיק הוא ContentOffs. CmsSliceTlv קיימת כדי לגשר על הפער הזה: בהינתן היסט תוכן ואורך היא מחזירה תג, אוקטטי אורך ותוכן כמאגר אחד, ו-AddSignatureTimestampToCms קוראת לה עבור ה-OID של contentType, ה-INTEGER של version, ה-SET של digestAlgorithms, ה-SEQUENCE של encapContentInfo וסט ה-certificates [0] כשהוא קיים
// בתוך AddSignatureTimestampToCms: לרדת פנימה, לחתוך את האחים מילולית
if not R.ReadTlv(Tag, CO, CL, CN) or (Tag<> Byte(asnSequence)) then
raise Exception.Create('CMS: encapContentInfo SEQUENCE expected');
SdEncapTlv:= CmsSliceTlv(CmsDer, CO, CL);
R.Position:= CN;
// certificates [0] אופציונלי
HasCerts:= (R.Position< SdEnd) and (CmsDer[R.Position]= $A0);
if HasCerts then
begin
if not R.ReadTlv(Tag, CO, CL, CN) or (Tag<> $A0) then
raise Exception.Create('CMS: certificates [0] malformed');
SdCertsTlv:= CmsSliceTlv(CmsDer, CO, CL); // תג + אוקטטי אורך + תוכן
R.Position:= CN;
end;
מבין חמש החתיכות האלה, ארבע זעירות: OID בן אחד עשר בתים, INTEGER בן שלושה בתים, סט אלגוריתם עיכול בן שבעה עשר בתים, ו-encapContentInfo מנותק בן שלושה עשר בתים. סט התעודות הוא זה שנושא את תעודת החותם ואת השרשרת שלה, ותעודת X.509 אמיתית מגיעה למאות בתים לכל הפחות. סט התעודות הוא לכן החתיכה היחידה שאוקטטי האורך שלה נמצאים אי פעם בצורה הארוכה, והוא החתיכה שהעוזר הישן לא הצליח לאתר
למה אי אפשר ללכת אחורה על אוקטטי אורך של DER?
כי מספר אוקטטי האורך שמור בראשון שבהם, וקריאה מהתוכן אחורה פוגשת את האחרון ראשון. X.690 סעיף 8.1.3.4 מגדיר את הצורה הקצרה: אוקטט אחד, ביט 8 כבוי, ביטים 7 עד 1 מחזיקים אורך מ-0 עד 127. סעיף 8.1.3.5 מגדיר את הצורה הארוכה: אוקטט פתיחה עם ביט 8 דלוק שביטיו 7 עד 1 נותנים את מספר האוקטטים העוקבים, ואחריו אותם אוקטטים שנושאים את האורך כמספר שלם חסר סימן בסדר בתים גדול. שום דבר בכלל לא מסמן אוקטט עוקב כעוקב. הביט ה-8 שלו הוא ביט גודל כמו כל אחר, כך שהליכה אחורה שבודקת את הביט העליון של Buf[ContentOffs- 1] בודקת ביט נתונים ואז קוראת את שבעת הביטים התחתונים שלו כמניין
// העוזר הישן, כשהוא מקבל רק את היסט התוכן
function CmsHeaderStart(const Buf: TBytes; ContentOffs: Integer): Integer;
var
P, LenByte, LongLen: Integer;
begin
P:= ContentOffs- 1; // נוחת על אוקטט האורך האחרון
if P< 0 then
Exit(ContentOffs);
LenByte:= Buf[P];
if (LenByte and $80)= 0 then // משמעותי רק עבור הראשון
Result:= P- 1
else
begin
LongLen:= LenByte and $7F;
Result:= P- LongLen- 1;
end;
end;
// הכותרת של סט תעודות בנפח 1500 בתים: A0 82 05 DC
// Buf[ContentOffs- 1]= $DC -> ביט 8 דלוק, $DC and $7F= 92
// Result= ContentOffs- 94 (התג נמצא ב-ContentOffs- 4)
קחו את הכותרת של סט תעודות שמחזיק 1500 בתים של תעודות, A0 82 05 DC. ההליכה נוחתת על DC, רואה ביט עליון דלוק, מחלצת 92 משבעת הביטים התחתונים ומדווחת שהתג נמצא 94 בתים לפני התוכן, כשהוא 4 בתים לפניו. ב-SignedData שנבנה על ידי BuildSignedData, תוכן סט התעודות יושב רק כמה עשרות בתים לתוך ה-CMS, כך שההיסט שחושב לא היה רק מוקדם אלא שלילי, והקוד הישן הגן על ContentOffs- 1 מפני ירידה מתחת לאפס, לא על התוצאה הסופית שלו. CmsSliceTlv לקחה אז חתיכה ארוכה בתשעים ומשהו בתים מהאלמנט, שהתחילה לפני המאגר, וה-SignedData שנבנה מחדש נשא את החתיכה הזו במקום שבו סט התעודות שלו היה אמור להיות. אורך בן שלושה אוקטטים שהאוקטט האחרון שלו במקרה נפל מתחת ל-$80, נניח A0 82 05 10, נכשל בכיוון האחר: ההליכה חשבה אותו לאוקטט בצורה קצרה והתחילה את החתיכה ב-05, שני בתים מאוחר מדי ובתוך אוקטטי האורך, בלי תג בכלל. התוצאה הייתה שגויה בשני המקרים, רק הכיוון השתנה
מה DER מבטיח שהופך את הגזירה קדימה למדויקת?
DER מבטיח שקידוד האורך הוא פונקציה טהורה של האורך. X.690 סעיף 10.1 מגביל את DER לצורה המוגדרת ודורש את מספר האוקטטים המזערי, מה שמסיר את שתי החירויות ש-BER מתיר: הצורה הבלתי מוגדרת, וריפוד אורך בצורה ארוכה באוקטטי אפס מובילים. תחת הכלל הזה לאורך תוכן מתחת ל-128 יש בדיוק אוקטט אורך אחד, ולכל אורך אחר יש אוקטט פתיחה אחד ועוד בדיוק כמה אוקטטים עוקבים כמו בתים משמעותיים שהאורך צריך. הקורא של CmsHeaderStart מחזיק ממילא את ContentLen, כי ReadTlv זה עתה החזיר אותו, כך שאורך הכותרת ניתן לחישוב בלי להסתכל על אף בית במאגר
// העוזר שנשלח: לגזור את הכותרת מאורך התוכן.
// לפי X.690 10.1 אוקטטי האורך הם פונקציה של ContentLen
function CmsHeaderStart(const Buf: TBytes; ContentOffs, ContentLen: Integer): Integer;
var
LengthOctets, Remaining: Integer;
begin
if ContentLen< 128 then
LengthOctets:= 1 // הצורה הקצרה, X.690 8.1.3.4
else
begin
LengthOctets:= 1; // אוקטט הפתיחה, X.690 8.1.3.5
Remaining:= ContentLen;
while Remaining> 0 do
begin
Inc(LengthOctets); // אחד לכל בית משמעותי
Remaining:= Remaining shr 8;
end;
end;
Result:= ContentOffs- LengthOctets- 1;
if Result< 0 then
Result:= ContentOffs;
end;
שני פרטים הופכים את זה לבטוח ולא רק לסביר. ראשית, ההנחה שהקלט הוא DER נאכפת במעלה הזרם: TDerReader.TryReadTlvAt, שעליו בנוי ReadTlv, דוחה את הצורה הבלתי מוגדרת, דוחה אורך בצורה ארוכה שהאוקטט העוקב הראשון שלו אפס, ודוחה אוקטט עוקב בודד מתחת ל-$80. TLV שמגיע ל-CmsSliceTlv כבר עבר את הבדיקות האלה, כך שאורך לא מזערי בסגנון BER לא יכול להגיע לגזירה ולגרום לה לשקר. שנית, הנפילה לאחור עבור תוצאה שלילית מגנה כעת על התוצאה האמיתית, לא על תוצאה ביניים. ראוי לומר שהקורא ידע את היסט התג לכל אורך הדרך: TDerTlv נושאת גם Offset וגם HeaderLength, ורק משטח ReadTlv עם ארבעת פרמטרי הפלט משליך אותן. החזרתן תהיה הממשק הנקי יותר לטווח ארוך; התיקון שנשלח שומר על המשטח הזה כמו שהוא ועושה את העוזר לנכון במונחים שלו
למה בדיקות חתימת הזמן עברו כשהבאג היה במקום?
כי כל תעודת בדיקה הייתה קצרה מספיק כדי להשתמש בצורה הקצרה, וההליכה אחורה נכונה בדיוק במקרה הזה. Tests.PadesTimestamp.pas בונה את תעודת החותם שלה עם SetLength(SignerCertDer, 32) בבדיקה אחת ועם 64 באחרת, ממולאת ברמפת בתים. סט תעודות בן 32 בתים מקודד כ-A0 20 ובן 64 בתים כ-A0 40, אוקטט אורך בודד כל אחד. הליכה אחורה מהתוכן נוחתת על האוקטט האחד הזה, הביט העליון שלו כבוי כי הוא אוקטט האורך הראשון והיחיד, והעוזר עונה נכון מהסיבה השגויה. מערכת של 1414 מקרים הייתה ירוקה, ה-CMS עם חתימת הזמן נפרס, מאמת השלב הראשון דיווח B-T, וכל אחת מהבדיקות האלה רצה מול סט תעודות שאף מסמך אמיתי מעולם לא הכיל
הכלל הכללי הוא החלק המועיל. בכל פעם שמסלול קוד תלוי באיך אורך מקודד, קובץ הבדיקה חייב לחצות את גבול הקידוד, ועבור DER זה אומר תוכן ארוך מ-127 בתים, שכופה את הצורה הארוכה, ובאידיאלי גם ארוך מ-255 בתים, שכופה אוקטט עוקב שני. אותה משמעת חלה על המקרה האחר בסקירה ההיא שבו אימות עצמי לא יכול היה לראות סטייה ב-DER: ה-SET OF הלא ממוין ב-signedAttrs היה בלתי נראה להלוך ושוב מאותו מקור מאותה סיבה מבנית בדיוק, הבדיקה תרגלה רק קלטים שעליהם הקוד השגוי והקוד הנכון מסכימים. הסקיצה למטה קוראת לעוזר החיתוך ישירות, מה שאומר לייצא אותו מ-FPdfCms.pas לבניית הבדיקות; אותו גבול נגיש דרך המשטח הציבורי בכך שנותנים ל-BuildSignedData תעודת שרשרת בכל גודל ומפרסים מחדש את התוצאה עם חתימת הזמן
// לקבע את הגבול: חתיכה דרך כותרת בצורה ארוכה חייבת להתחיל בתג
const
Lens: array[0..6] of Integer= (127, 128, 255, 256, 1500, 65535, 65536);
procedure TCmsSliceTests.HeaderStart_LongFormLengths;
var
W: TDerWriter;
Content, Tlv: TBytes;
I, Len: Integer;
begin
W:= TDerWriter.Create;
try
for I:= Low(Lens) to High(Lens) do
begin
Len:= Lens[I];
SetLength(Content, Len);
Tlv:= W.Wrap($A0, Content); // A0 7F / A0 81 80 / A0 82 05 DC ...
W.Clear;
// התוכן מתחיל מיד אחרי הכותרת; החתיכה חייבת להיות ה-TLV כולו
Assert.AreEqual(Length(Tlv),
Length(CmsSliceTlv(Tlv, Length(Tlv)- Len, Len)),
'slice through header of a '+ IntToStr(Len)+ '-byte content');
end;
finally
W.Free;
end;
end;
איפה הבנייה מחדש עדיין משרטטת את הגבולות שלה
AddSignatureTimestampToCms נכתבה בשביל ה-CMS ש-BuildSignedData פולטת, והגבולות שלה נובעים מכך. ההליכה מצפה ל-SignerInfo יחיד ופולטת מחדש רק אותו, כך ש-CMS זר עם כמה חותמים יחזור עם חותם אחד; היא מזהה סט certificates [0] אופציונלי אבל לא סט crls [1], ו-CMS שנושא אחד כזה נכשל בקול רם עם החריגה signerInfos SET expected במקום לחתוך לא נכון בשקט. ה-unsignedAttrs החדש מחזיק מאפיין אחד, כך שכלל סדר ה-SET OF של X.690 סעיף 11.6 מסופק באופן טריוויאלי ולא צריך מיון. והחלק החתום לא נגוע מעצם הבנייה: הקידומת של ה-SignerInfo עד ה-OCTET STRING של החתימה מועתקת מילולית, ולכן מאמת שמעכל מחדש את signedAttrs רואה את אותם בתים לפני ואחרי שהחתימה בזמן מתווספת. כשמישהו בכל זאת דוחה את המסמך, הסיבות בדרך כלל נמצאות במקום אחר וראויות לרשימת בדיקה משלהן
קורא ה-DER, הכותב, בונה ה-CMS והזרקת חתימת הזמן הזו כולם מגיעים כקוד פסקל עם רכיב PDFium ל-Delphi, ובאג מהצורה הזו הוא הטיעון לכך: כשה-SignedData שנבנה מחדש יוצא ארוך בתשעים בתים מדי, אתם רוצים לקרוא את העוזר שחתך את החתיכה ואת הסעיף ב-X.690 שהוא קרא לא נכון, לא מחסנית קריאות מקופסה שחורה