Техническая статья

Не ходите по октетам длины DER назад: PDFium Component

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 тянет как минимум на несколько сотен байт. Поэтому набор сертификатов — единственная вырезка, чьи октеты длины вообще бывают в длинной форме, и именно её старый хелпер найти не мог

Что пересобирает добавление метки времени PAdES B-T в CMS: CmsSliceTlv копирует contentType, version, digestAlgorithms, encapContentInfo и набор сертификатов байт в байт; набор сертификатов — единственная вырезка, достаточно длинная, чтобы выйти из короткой формы длины; а каждый объемлющий заголовок от SignerInfo до ContentInfo выдаётся заново
Подписанная часть не тронута по построению, потому что префикс SignerInfo вплоть до OCTET STRING подписи копируется дословно, так что валидатор, заново считающий дайджест signedAttrs, видит одинаковые байты до и после попадания метки времени

Почему октеты длины 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 нельзя читать назад: чтение A0 82 05 DC с конца попадает на последний октет DC, чей выставленный старший бит даёт фиктивный счётчик 92 и ставит тег на 94 октета раньше, тогда как A0 82 05 10 отказывает с другой стороны и начинает вырезку на два октета позже, внутри октетов длины
Старый CmsHeaderStart защищал промежуточное вычитание вместо итогового результата, так что вырезка могла начаться даже раньше буфера, и пересобранная SignedData несла эту вырезку там, где должен был быть её набор сертификатов

Что гарантирует DER, что прямой вывод точен?

DER гарантирует, что кодирование длины — чистая функция от длины. X.690 clause 10.1 ограничивает DER определённой формой и требует минимального числа октетов, что убирает две свободы, которые допускает BER: неопределённую форму и добивание длины длинной формы ведущими нулевыми октетами. По этому правилу длина содержимого меньше 128 имеет ровно один октет длины, а любая другая — один начальный октет плюс ровно столько последующих, сколько нужно значащих байт для длины. Вызывающий CmsHeaderStart уже держит ContentLen, потому что ReadTlv только что его вернул, так что длину заголовка можно вычислить, не глядя ни на один байт буфера

Прямой вывод, который гарантирует DER: длина содержимого 127 кодируется как A0 7F, 128 — как A0 81 80, 255 — как A0 81 FF, 256 — как A0 82 01 00, а 1500 — как A0 82 05 DC, так что длина заголовка следует из одного только ContentLen, а TryReadTlvAt уже отверг каждую неминимальную форму BER
Тестовые данные из сертификатов по 32 и 64 байта оставались внутри короткой формы, где обратный проход отвечает верно по неверной причине, поэтому набор на границах теперь пересекает 127, 128, 255 и 256 байт
// Поставляемый хелпер: выводим заголовок из длины содержимого.
// По 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, который он неверно понял, а не стек вызовов из чёрного ящика