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
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
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 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