PDFium Component находит начало вложенной структуры CMS по длине её содержимого и никогда не идёт назад по октетам длины, потому что байт прямо перед содержимым — это последний октет длины, и он ничего не говорит о том, сколько их было до него. CmsHeaderStart в FPdfCms.pas вместо этого выводит длину заголовка из ContentLen, что DER делает точным, и именно это не даёт AddSignatureTimestampToCms портить любой CMS, чей набор сертификатов длиннее 127 байт
Контекст — обновление до PAdES B-T. Атрибут метки времени подписи, тот самый, что определён в 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, затем обёртки 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 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 был невидим для кругового рейса в пределах одного источника по структурно той же причине — тест упражнял только те входы, на которых неверный код и верный соглашаются. Набросок ниже зовёт хелпер вырезки напрямую, а значит его нужно экспортировать из 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, и баг такой формы — как раз аргумент за это: когда пересобранная SignedData выходит на девяносто байт длиннее, хочется прочитать хелпер, вырезавший кусок, и пункт X.690, который он неверно понял, а не стек вызовов из чёрного ящика