PDFium Component визначає початок вкладеної структури CMS із довжини її вмісту, а не зворотним обходом октетів довжини, бо байт безпосередньо перед вмістом — це останній октет довжини, і він нічого не каже про те, скільки їх передує. CmsHeaderStart у FPdfCms.pas натомість виводить довжину заголовка з ContentLen, що DER робить точним, і саме це не дає AddSignatureTimestampToCms попсувати кожен CMS, чий набір сертифікатів довший за 127 байтів
Ідеться про оновлення PAdES B-T. Атрибут signature-time-stamp, той, що його ETSI EN 319 122-1 clause 5.3 визначає під OID 1.2.840.113549.1.9.16.2.14, мусить лягти в unsignedAttrs структури SignerInfo, описаної в RFC 5652 clause 5.3, і за визначенням його можна додати лише після того, як значення підпису вже існує, бо токен мітки часу обчислюється саме над цим значенням. Тож коли токен прибуває, CMS уже збудований і вже підписаний. Додавання одного атрибута змінює довжину SignerInfo, що змінює довжину SET signerInfos, потім SignedData, потім обгортки [0] EXPLICIT, а потім зовнішнього 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 clause 8.1.3.4 визначає коротку форму: один октет, біт 8 скинутий, біти з 7 по 1 тримають довжину від 0 до 127. Clause 8.1.3.5 визначає довгу форму: початковий октет із виставленим бітом 8, чиї біти з 7 по 1 дають кількість наступних октетів, а за ними ті октети, що несуть довжину як беззнакове big-endian ціле. Ніщо в правилі не позначає наступний октет як наступний. Його біт 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 clause 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 був невидимий для round-trip у межах того самого джерела зі структурно ідентичної причини — тест задіював лише входи, на яких хибний код і правильний код збігаються. Нарис нижче викликає хелпер зрізу напряму, а це означає експортувати його з 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 clause 11.6 виконується тривіально й сортування не потребує. А підписана частина за побудовою не зачеплена: префікс SignerInfo аж до OCTET STRING підпису копіюється дослівно, саме тому валідатор, який перехешовує signedAttrs, бачить ті самі байти до і після додавання мітки часу. Коли він усе ж відкидає документ, причини зазвичай деінде, і вони варті власного чекліста
Читач DER, записувач, будівник CMS і оце впорскування мітки часу — усе це постачається як вихідний код Pascal разом із PDFium Delphi component, і баг такої форми — це і є аргумент за це: коли перебудований SignedData виходить на дев'яносто байтів задовгим, ви хочете прочитати хелпер, який вирізав зріз, і той clause X.690, який він прочитав неправильно, а не стек викликів із чорної скриньки