Technical Article

Never Walk DER Length Octets Backwards: PDFium Component

PDFium Component locates the start of a nested CMS structure from its content length, never by walking backwards over the length octets, because the byte just before the content is the last length octet and says nothing about how many precede it. CmsHeaderStart in FPdfCms.pas derives the header length from ContentLen instead, which DER makes exact, and that is what keeps AddSignatureTimestampToCms from corrupting every CMS whose certificate set is longer than 127 bytes

The setting is the PAdES B-T upgrade. A signature-time-stamp attribute, the one ETSI EN 319 122-1 clause 5.3 defines under OID 1.2.840.113549.1.9.16.2.14, has to land in the unsignedAttrs of the SignerInfo described in RFC 5652 clause 5.3, and by definition it can only be added after the signature value exists, because the timestamp token is computed over that value. So the CMS is already built and already signed when the token arrives. Adding one attribute changes the length of the SignerInfo, which changes the length of the signerInfos SET, then of SignedData, then of the [0] EXPLICIT wrapper, then of the outer ContentInfo. Every enclosing header has to be re-emitted, and everything that is not on that path has to be carried across byte for byte. The B-LT and B-LTA walkthrough covers what the token buys you; this article is about the four bytes in front of the certificate set that the rebuild kept getting wrong

Why does adding a timestamp need the tag offset of a sibling?

Because the rebuild reuses four siblings of the signerInfos SET verbatim, and the reader reports where their content is, not where their tag is. TDerReader.ReadTlv hands back the tag byte, the content offset, the content length and the offset of the next TLV. That is the right surface for descending into a structure, but to copy a whole element you need the octet where its tag sits, and the only thing a caller holds is ContentOffs. CmsSliceTlv exists to bridge that gap: given a content offset and length it returns tag, length octets and content as one buffer, and AddSignatureTimestampToCms calls it for the contentType OID, the version INTEGER, the digestAlgorithms SET, the encapContentInfo SEQUENCE and, when present, the certificates [0] set

// Inside AddSignatureTimestampToCms: descend, slice the siblings verbatim
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;
// optional 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);   // tag + length octets + content
  R.Position:= CN;
end;

Of those five slices, four are tiny: an eleven-byte OID, a three-byte INTEGER, a seventeen-byte digest algorithm set, a thirteen-byte detached encapContentInfo. The certificate set is the one that carries the signer certificate and its chain, and a real X.509 certificate runs to several hundred bytes at the very least. The certificate set is therefore the only slice whose length octets are ever in the long form, and it is the slice the old helper could not locate

What adding a PAdES B-T timestamp rebuilds in a CMS: CmsSliceTlv copies contentType, version, digestAlgorithms, encapContentInfo and the certificates set byte for byte, the certificate set is the only slice long enough to leave the short length form, and every enclosing header from SignerInfo up to ContentInfo is re-emitted
The signed portion is untouched by construction because the SignerInfo prefix through the signature OCTET STRING is copied verbatim, so a validator that re-digests signedAttrs sees identical bytes before and after the timestamp lands

Why can DER length octets not be walked backwards?

Because the count of length octets is stored in the first of them, and reading from the content backwards you meet the last one first. X.690 clause 8.1.3.4 defines the short form: one octet, bit 8 clear, bits 7 to 1 holding a length from 0 to 127. Clause 8.1.3.5 defines the long form: an initial octet with bit 8 set whose bits 7 to 1 give the number of subsequent octets, followed by those octets carrying the length as an unsigned big-endian integer. Nothing in the rule marks a subsequent octet as subsequent. Its bit 8 is a magnitude bit like any other, so a backwards walk that tests the top bit of Buf[ContentOffs- 1] is testing a data bit and then reading its low seven bits as a count

// The old helper, given only the content offset
function CmsHeaderStart(const Buf: TBytes; ContentOffs: Integer): Integer;
var
  P, LenByte, LongLen: Integer;
begin
  P:= ContentOffs- 1;            // lands on the LAST length octet
  if P< 0 then
    Exit(ContentOffs);
  LenByte:= Buf[P];
  if (LenByte and $80)= 0 then   // only meaningful for the FIRST one
    Result:= P- 1
  else
  begin
    LongLen:= LenByte and $7F;
    Result:= P- LongLen- 1;
  end;
end;

// Header of a 1500-byte certificate set:  A0 82 05 DC
//   Buf[ContentOffs- 1]= $DC  -> bit 8 set, $DC and $7F= 92
//   Result= ContentOffs- 94     (the tag is at ContentOffs- 4)

Take the header of a certificate set holding 1500 bytes of certificates, A0 82 05 DC. The walk lands on DC, sees a set top bit, extracts 92 from the low seven bits and reports the tag 94 bytes before the content, when it is 4 bytes before it. In a SignedData built by BuildSignedData, the certificate set content sits only a few dozen bytes into the CMS, so the computed offset was not merely early but negative, and the old code guarded ContentOffs- 1 against going below zero, not its final result. CmsSliceTlv then took a slice ninety-odd bytes longer than the element, starting before the buffer, and the rebuilt SignedData carried that slice where its certificate set should have been. A three-octet length whose last octet happened to fall below $80, say A0 82 05 10, failed the other way: the walk took it for a short-form octet and started the slice at 05, two bytes late and inside the length octets, with no tag at all. The outcome was wrong either way, only the direction varied

Why DER length octets cannot be walked backwards: reading A0 82 05 DC from the end lands on the last octet DC, whose set top bit yields a bogus count of 92 and places the tag 94 octets early, while A0 82 05 10 fails the other way and starts the slice two octets late inside the length octets
The old CmsHeaderStart guarded the intermediate subtraction instead of its final result, so a slice could even begin before the buffer, and the rebuilt SignedData carried that slice where its certificate set belonged

What does DER guarantee that makes the forward derivation exact?

DER guarantees that the length encoding is a pure function of the length. X.690 clause 10.1 restricts DER to the definite form and requires the minimum number of octets, which removes the two freedoms BER allows: the indefinite form, and padding a long-form length with leading zero octets. Under that rule a content length below 128 has exactly one length octet, and any other length has one initial octet plus exactly as many subsequent octets as the length needs significant bytes. The caller of CmsHeaderStart already holds ContentLen, because ReadTlv just returned it, so the header length is computable without looking at a single byte of the buffer

The forward derivation DER guarantees: a content length of 127 encodes as A0 7F, 128 as A0 81 80, 255 as A0 81 FF, 256 as A0 82 01 00 and 1500 as A0 82 05 DC, so the header length follows from ContentLen alone and TryReadTlvAt has already rejected every non-minimal BER form
Fixtures built from 32-byte and 64-byte certificates stayed inside the short form where the backwards walk answers correctly for the wrong reason, which is why the boundary suite now crosses 127, 128, 255 and 256 bytes
// The shipped helper: derive the header from the content length.
// Under X.690 10.1 the length octets are a function of ContentLen
function CmsHeaderStart(const Buf: TBytes; ContentOffs, ContentLen: Integer): Integer;
var
  LengthOctets, Remaining: Integer;
begin
  if ContentLen< 128 then
    LengthOctets:= 1                 // short form, X.690 8.1.3.4
  else
  begin
    LengthOctets:= 1;                // the initial octet, X.690 8.1.3.5
    Remaining:= ContentLen;
    while Remaining> 0 do
    begin
      Inc(LengthOctets);             // one per significant byte
      Remaining:= Remaining shr 8;
    end;
  end;
  Result:= ContentOffs- LengthOctets- 1;
  if Result< 0 then
    Result:= ContentOffs;
end;

Two details make this safe rather than merely plausible. First, the assumption that the input is DER is enforced upstream: TDerReader.TryReadTlvAt, which ReadTlv is built on, rejects the indefinite form, rejects a long-form length whose first subsequent octet is zero, and rejects a single subsequent octet below $80. A TLV that reaches CmsSliceTlv has already passed those checks, so a BER-style non-minimal length cannot reach the derivation and make it lie. Second, the fallback for a negative result now guards the real answer, not an intermediate. It is worth saying that the reader knew the tag offset all along: TDerTlv carries both Offset and HeaderLength, and only the four-out-parameter ReadTlv surface drops them. Returning them would be the cleaner long-term interface; the shipped fix keeps that surface intact and makes the helper correct on its own terms

Why did the timestamp tests pass with the bug in place?

Because every fixture certificate was short enough to use the short form, and the backwards walk is correct for exactly that case. Tests.PadesTimestamp.pas builds its signer certificate with SetLength(SignerCertDer, 32) in one test and 64 in another, filled with a byte ramp. A 32-byte certificate set encodes as A0 20 and a 64-byte one as A0 40, a single length octet each. Walking backwards from the content lands on that one octet, its top bit is clear because it is the first and only length octet, and the helper answers correctly for the wrong reason. The suite of 1414 cases was green, the timestamped CMS parsed, the stage-1 validator reported B-T, and every one of those checks was run against a certificate set that no real document has ever contained

The general rule is the useful part. Whenever a code path depends on how a length is encoded, the fixture has to cross the encoding boundary, and for DER that means content longer than 127 bytes, which forces the long form, and ideally longer than 255 bytes as well, which forces a second subsequent octet. The same discipline applies to the other case in that review where self-verification could not see a DER deviation: the unsorted SET OF in signedAttrs was invisible to a same-origin round trip for a structurally identical reason, the test exercised only inputs on which the wrong code and the right code agree. The sketch below calls the slice helper directly, which means exporting it from FPdfCms.pas for the test build; the same boundary is reachable through the public surface by handing BuildSignedData a chain certificate of each size and re-parsing the timestamped result

// Pin the boundary: a slice through a long-form header must start at the tag
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;
      // content begins right after the header; the slice must be the whole 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;

Where the rebuild still draws its lines

AddSignatureTimestampToCms is written for the CMS that BuildSignedData emits, and its limits follow from that. The walk expects a single SignerInfo and re-emits only that one, so a foreign multi-signer CMS would come back with one signer; it recognises an optional certificates [0] set but not a crls [1] set, and a CMS carrying one fails loudly with the signerInfos SET expected exception rather than quietly mis-slicing. The new unsignedAttrs holds one attribute, so the SET OF ordering rule of X.690 clause 11.6 is trivially satisfied and needs no sort. And the signed portion is untouched by construction: the SignerInfo prefix through the signature OCTET STRING is copied verbatim, which is why a validator that re-digests signedAttrs sees the same bytes before and after the timestamp is added. When one still turns the document down, the causes are usually elsewhere and worth their own checklist

The DER reader, the writer, the CMS builder and this timestamp injection all ship as Pascal source with the PDFium Delphi component, and a bug of this shape is the argument for that: when a rebuilt SignedData comes out ninety bytes too long, you want to read the helper that cut the slice and the clause of X.690 it misread, not a stack trace from a black box