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

DER length октетите не се обхождат назад: PDFium Component

PDFium Component намира началото на вложена CMS структура от дължината на съдържанието ѝ, никога чрез обхождане назад върху length октетите, защото байтът непосредствено пред съдържанието е последният length октет и не казва нищо за това колко предхождат него. CmsHeaderStart в FPdfCms.pas извежда дължината на header-а от ContentLen, което DER прави точно, и точно това попречи на AddSignatureTimestampToCms да поврежда всяка CMS, чието certificate множество е по-дълго от 127 байта

Обстановката е ъпгрейдът към PAdES B-T. Атрибут signature-time-stamp — онзи, който ETSI EN 319 122-1, точка 5.3, дефинира под OID 1.2.840.113549.1.9.16.2.14 — трябва да кацне в unsignedAttrs на SignerInfo, описан в RFC 5652, точка 5.3, и по дефиниция може да бъде добавен само след като стойността на подписа вече съществува, защото timestamp токенът се изчислява върху нея. Така че CMS-ът вече е изграден и вече подписан, когато токенът пристигне. Добавянето на един атрибут сменя дължината на SignerInfo, което сменя дължината на SET signerInfos, после на SignedData, после на обвивката [0] EXPLICIT, после на външния ContentInfo. Всеки обгръщащ header трябва да се излъчи наново, а всичко, което не е по този път, трябва да се пренесе байт по байт. Разходката из B-LT и B-LTA покрива какво ви купува токенът; тази статия е за четирите байта пред certificate множеството, които rebuild-ът продължаваше да бърка

Защо добавянето на timestamp се нуждае от таговото отместване на събрат?

Защото rebuild-ът преизползва четири събрата на SET signerInfos дословно, а reader-ът докладва къде е тяхното съдържание, не къде е техният таг. TDerReader.ReadTlv връща таговия байт, отместването на съдържанието, дължината на съдържанието и отместването на следващия TLV. Това е правилната повърхност за слизане в структура, но за да копирате цял елемент, ви трябва октетът, на който седи неговият таг, а единственото, което caller-ът държи, е ContentOffs. CmsSliceTlv съществува, за да мости тази пролука: по дадени отместване и дължина на съдържанието той връща таг, length октети и съдържание като един буфер, а 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);   // таг + length октети + съдържание
  R.Position:= CN;
end;

От тези пет среза четири са мънички: единадесет-байтов OID, три-байтов INTEGER, седемнайсет-байтов набор digest алгоритми, тринайсет-байтов detached encapContentInfo. Certificate множеството е това, което носи сертификата на подписващия и неговата верига, а истински X.509 сертификат върви с няколкостотин байта в най-добрия случай. Certificate множеството следователно е единственият срез, чиято length октети изобщо влизат в дългата форма, и е срезът, който старият helper не можеше да намери

Какво възстановява добавянето на PAdES B-T timestamp в CMS: CmsSliceTlv копира contentType, version, digestAlgorithms, encapContentInfo и множеството certificates байт по байт, certificate множеството е единственият срез, достатъчно дълъг да напусне кратката length форма, и всеки обгръщащ header от SignerInfo до ContentInfo се излъчва наново
Подписаната част е недокосната по конструкция, защото префиксът на SignerInfo през OCTET STRING на подписа се копира дословно, така че валидатор, пре-дайджестващ signedAttrs, вижда идентични байтове преди и след кацането на timestamp-а

Защо DER length октетите не могат да се обхождат назад?

Защото броят на length октетите се съхранява в първия от тях, а четейки от съдържанието назад, срещате последния пръв. X.690, точка 8.1.3.4, дефинира кратката форма: един октет, бит 8 изчистен, битове 7 до 1 носят дължина от 0 до 127. Точка 8.1.3.5 дефинира дългата форма: начален октет с вдигнат бит 8, чийто битове 7 до 1 дават броя на последващите октети, следвани от тези октети, носещи дължината като unsigned big-endian цяло число. Нищо в правилото не маркира един последващ октет като последващ. Бит 8 при него е бит за величина като всеки друг, така че обхождане назад, тестващо най-горния бит на Buf[ContentOffs- 1], тества бит данни и после чете ниските му седем бита като бройка

// Старият helper, получаващ само отместването на съдържанието
function CmsHeaderStart(const Buf: TBytes; ContentOffs: Integer): Integer;
var
  P, LenByte, LongLen: Integer;
begin
  P:= ContentOffs- 1;            // каца на ПОСЛЕДНИЯ length октет
  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;

// Header на certificate набор от 1500 байта:  A0 82 05 DC
//   Buf[ContentOffs- 1]= $DC  -> бит 8 вдигнат, $DC and $7F= 92
//   Result= ContentOffs- 94     (тагът е на ContentOffs- 4)

Вземете header-а на certificate множество, държащо 1500 байта сертификати, A0 82 05 DC. Обхождането каца на DC, вижда вдигнат горен бит, извлича 92 от ниските седем бита и докладва тага 94 байта преди съдържанието, когато той е 4 байта преди него. В SignedData, построен от BuildSignedData, съдържанието на certificate множеството седи само на няколко десетки байта вътре в CMS, така че изчисленото отместване не беше просто преждевременно, а отрицателно, а старият код пазеше ContentOffs- 1 от слизане под нула, не крайния си резултат. CmsSliceTlv тогава взимаше срез с деветдесет-и-няколко байта по-дълъг от елемента, започващ преди буфера, и възстановеният SignedData носеше този срез там, където трябваше да е certificate множеството му. Три-октетна дължина, чийто последен октет случайно падаше под $80, да речем A0 82 05 10, проваляше в другата посока: обхождането го взимаше за кратко-формен октет и започваше среза на 05 — два байта късно и вътре в length октетите, без никакъв таг. Резултатът беше грешен и в двата случая, различаваше се само посоката

Защо DER length октетите не могат да се обхождат назад: четенето на A0 82 05 DC от края каца на последния октет DC, чиито вдигнат горен бит дава фалшива бройка 92 и поставя тага 94 октета рано, докато A0 82 05 10 проваля в другата посока и стартира среза два октета късно, вътре в length октетите
Старият CmsHeaderStart пазеше междинното изваждане вместо крайния си резултат, така че срез можеше дори да започне преди буфера, а възстановеният SignedData носеше този срез там, където му принадлежеше certificate множеството

Какво гарантира DER, което прави предното извеждане точно?

DER гарантира, че кодирането на дължината е чиста функция на дължината. X.690, точка 10.1, ограничава DER до определената форма и изисква минималния брой октети, което премахва двете свободи, които BER позволява: неопределената форма и подплънването на long-form дължина с водещи нулеви октети. Под това правило съдържание под 128 има точно един length октет, а всяка друга дължина има един начален октет плюс точно толкова последващи октети, колкото значащи байта дължината се нуждае. Caller-ът на CmsHeaderStart вече държи ContentLen, защото ReadTlv току-що го е върнал, така че дължината на header-а се изчислява, без да се поглежда дори един байт от буфера

Предното извеждане, което DER гарантира: съдържание от 127 се кодира като A0 7F, 128 като A0 81 80, 255 като A0 81 FF, 256 като A0 82 01 00 и 1500 като A0 82 05 DC, така че дължината на header-а следва само от ContentLen, а TryReadTlvAt вече е отхвърлил всяка неминимална BER форма
Фикстури, построени от 32-байтови и 64-байтови сертификати, останаха вътре в кратката форма, където обхождането назад отговаря вярно по погрешна причина, затова граничният suite вече пресича 127, 128, 255 и 256 байта
// Издаденият helper: извлечи header-а от дължината на съдържанието.
// Под X.690 10.1 length октетите са функция на 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, отхвърля неопределената форма, отхвърля long-form дължина, чийто първи последващ октет е нула, и отхвърля единичен последващ октет под $80. TLV, който стигне до CmsSliceTlv, вече е минал тези проверки, така че BER-стилна неминимална дължина не може да стигне извеждането и да го накара да лъже. Второ, fallback-ът за отрицателен резултат вече пази истинския отговор, а не междинен. Си струва да се каже, че reader-ът е знаел отместването на тага през цялото време: TDerTlv носи и Offset, и HeaderLength, а само четири-изходно-параметричната повърхност ReadTlv ги изпуска. Връщането им би било по-чистият дългосрочен интерфейс; издадената поправка пази тази повърхност недокосната и прави helper-а верен със собствени средства

Защо timestamp тестовете минаваха с бъга на място?

Защото всеки фикстурен сертификат беше достатъчно къс за кратката форма, а обхождането назад е вярно точно за този случай. Tests.PadesTimestamp.pas постройва сертификата на подписващия с SetLength(SignerCertDer, 32) в един тест и 64 в друг, изпълнени с байтова рампа. Certificate множество от 32 байта се кодира като A0 20, а от 64 байта — като A0 40, по един length октет всяко. Обхождането назад от съдържанието каца на този един октет, горният му бит е изчистен, защото той е първият и единствен length октет, и helper-ът отговаря вярно по погрешна причина. Suite-ът от 1414 случая беше зелен, timestamp-натият CMS се парсваше, stage-1 валидаторът докладваше B-T, и всяка една от тези проверки вървеше върху certificate множество, което никакъв истински документ някога не е съдържал

Общото правило е полезната част. Винаги, когато един code path зависи от това как е кодирана дължина, фикстурата трябва да пресече границата на кодирането, а за DER това означава съдържание, по-дълго от 127 байта, което налага дългата форма, и в идеалния случай и по-дълго от 255 байта, което налага втори последващ октет. Същата дисциплина важи и за другия случай в този преглед, където самопроверката не можеше да види DER отклонение: несортираният SET OF в signedAttrs беше невидим за round trip със същия произход по структурно идентична причина — тестът упражняваше само входове, върху които грешният код и верният код съгласуват. Скицата по-долу вика slice helper-а директно, което означава export от FPdfCms.pas за тестовия build; същата граница е достижима и през публичната повърхност, като подадете на BuildSignedData верижен сертификат от всеки размер и пре-парснете timestamp-натия резултат

// Заковаи границата: срез през long-form header трябва да започва от тага
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;
      // съдържанието започва веднага след header-а; срезът трябва да е целият 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;

Къде rebuild-ът все още чертае границите си

AddSignatureTimestampToCms е написан за CMS-а, който BuildSignedData излъчва, и лимитите му следват от това. Обхождането очаква един SignerInfo и пре-излъчва само него, така че чужд multi-signer CMS би се върнал с един подписващ; той разпознава опционално множество certificates [0], но не и множество crls [1], а CMS, носещ такова, проваля се гръмко с exception signerInfos SET expected, вместо тихо да реже грешно. Новият unsignedAttrs държи един атрибут, така че правилото за подредба SET OF на X.690, точка 11.6, се удовлетворява тривиално и не се нуждае от сортиране. И подписаната част е недокосната по конструкция: префиксът на SignerInfo през OCTET STRING на подписа се копира дословно, което е причината валидатор, пре-дайджестващ signedAttrs, да вижда същите байтове преди и след добавянето на timestamp-а. Когато той все пак отхвърли документа, причините обикновено са другаде и си заслужават собствен чеклист

DER reader-ът, writer-ът, CMS builder-ът и тази timestamp инжекция всички се доставят като Pascal source с PDFium Delphi компонента, и бъг с тази форма е аргументът за това: когато възстановен SignedData излезе с деветдесетина байта твърде дълъг, искате да прочетете helper-а, който е отрязал среза, и точката на X.690, която е погрешно прочел, а не stack trace от черна кутия