PDFium Component định vị điểm bắt đầu của một cấu trúc CMS lồng nhau từ độ dài nội dung của nó, không bao giờ bằng cách đi ngược qua các octet độ dài, vì byte ngay trước nội dung là octet độ dài cuối cùng và không nói gì về số octet đứng trước nó. CmsHeaderStart trong FPdfCms.pas thay vào đó suy ra độ dài header từ ContentLen, thứ mà DER khiến cho chính xác tuyệt đối, và đó là thứ giữ cho AddSignatureTimestampToCms không làm hỏng mọi CMS có tập certificate dài hơn 127 byte
Bối cảnh là nâng cấp PAdES B-T. Một attribute signature-time-stamp, đúng cái mà ETSI EN 319 122-1 điều 5.3 định nghĩa dưới OID 1.2.840.113549.1.9.16.2.14, phải nằm trong unsignedAttrs của SignerInfo mà RFC 5652 điều 5.3 mô tả, và theo định nghĩa thì nó chỉ có thể được thêm vào sau khi giá trị chữ ký đã tồn tại, vì timestamp token được tính trên chính giá trị đó. Nghĩa là CMS đã dựng xong và đã ký xong khi token đến. Thêm một attribute làm đổi độ dài của SignerInfo, kéo theo độ dài của SET signerInfos, rồi của SignedData, rồi của wrapper [0] EXPLICIT, rồi của ContentInfo ngoài cùng. Mọi header bao quanh đều phải được phát lại, và mọi thứ không nằm trên đường đó phải được mang qua nguyên từng byte. Bài hướng dẫn về B-LT và B-LTA nói token mua cho bạn điều gì; còn bài này nói về bốn byte nằm trước tập certificate mà lần dựng lại cứ làm sai
Vì sao thêm một timestamp lại cần offset thẻ của một phần tử anh em?
Vì lần dựng lại dùng lại nguyên văn bốn phần tử anh em của SET signerInfos, mà reader lại báo nội dung của chúng nằm ở đâu chứ không báo thẻ của chúng nằm ở đâu. TDerReader.ReadTlv trả về byte thẻ, offset nội dung, độ dài nội dung và offset của TLV kế tiếp. Đó là bề mặt đúng để đi xuống trong một cấu trúc, nhưng để sao chép cả một phần tử thì bạn cần octet nơi thẻ của nó nằm, mà thứ duy nhất người gọi cầm là ContentOffs. CmsSliceTlv tồn tại để bắc cầu khoảng đó: nhận offset và độ dài nội dung, nó trả về thẻ, các octet độ dài và nội dung như một buffer duy nhất, và AddSignatureTimestampToCms gọi nó cho OID contentType, INTEGER version, SET digestAlgorithms, SEQUENCE encapContentInfo và, khi có mặt, tập certificates [0]
// Trong AddSignatureTimestampToCms: đi xuống, cắt các phần tử anh em nguyên văn
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] tùy chọn
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); // thẻ + các octet độ dài + nội dung
R.Position:= CN;
end;
Trong năm lát cắt đó, bốn cái rất nhỏ: một OID mười một byte, một INTEGER ba byte, một tập digest algorithm mười bảy byte, một encapContentInfo detached mười ba byte. Tập certificate là cái mang certificate của người ký cùng chuỗi của nó, mà một certificate X.509 thật dài ít nhất vài trăm byte. Vì thế tập certificate là lát cắt duy nhất có các octet độ dài rơi vào dạng dài, và cũng chính là lát cắt mà helper cũ không định vị nổi
Vì sao không thể đi ngược các octet độ dài DER?
Vì số lượng octet độ dài được lưu trong chính octet đầu tiên, mà đọc ngược từ nội dung thì bạn gặp octet cuối cùng trước tiên. X.690 điều 8.1.3.4 định nghĩa dạng ngắn: một octet, bit 8 bằng 0, các bit 7 tới 1 mang độ dài từ 0 đến 127. Điều 8.1.3.5 định nghĩa dạng dài: một octet đầu có bit 8 bật với các bit 7 tới 1 cho biết số octet theo sau, tiếp đó là những octet mang độ dài như một số nguyên không dấu big-endian. Không có gì trong quy tắc đánh dấu một octet là octet theo sau. Bit 8 của nó cũng là một bit độ lớn như mọi bit khác, nên một phép đi ngược kiểm tra bit trên cùng của Buf[ContentOffs- 1] thật ra đang kiểm tra một bit dữ liệu rồi đọc bảy bit thấp của nó như một con số đếm
// Helper cũ, chỉ được cho offset nội dung
function CmsHeaderStart(const Buf: TBytes; ContentOffs: Integer): Integer;
var
P, LenByte, LongLen: Integer;
begin
P:= ContentOffs- 1; // rơi vào octet độ dài CUỐI CÙNG
if P< 0 then
Exit(ContentOffs);
LenByte:= Buf[P];
if (LenByte and $80)= 0 then // chỉ có nghĩa với octet ĐẦU TIÊN
Result:= P- 1
else
begin
LongLen:= LenByte and $7F;
Result:= P- LongLen- 1;
end;
end;
// Header của một tập certificate 1500 byte: A0 82 05 DC
// Buf[ContentOffs- 1]= $DC -> bit 8 bật, $DC and $7F= 92
// Result= ContentOffs- 94 (thẻ nằm ở ContentOffs- 4)
Lấy header của một tập certificate chứa 1500 byte certificate, A0 82 05 DC. Phép đi ngược rơi vào DC, thấy bit trên cùng được bật, rút ra 92 từ bảy bit thấp và báo thẻ nằm 94 byte trước nội dung, trong khi thực tế nó chỉ cách 4 byte. Trong một SignedData do BuildSignedData dựng, nội dung tập certificate chỉ nằm cách đầu CMS vài chục byte, nên offset tính ra không chỉ sớm mà còn âm, và code cũ chỉ canh ContentOffs- 1 khỏi xuống dưới 0 chứ không canh kết quả cuối cùng. CmsSliceTlv sau đó lấy một lát cắt dài hơn phần tử chín mươi mấy byte, bắt đầu từ trước cả buffer, và SignedData được dựng lại mang lát cắt đó vào đúng chỗ lẽ ra là tập certificate. Một độ dài ba octet có octet cuối tình cờ nhỏ hơn $80, ví dụ A0 82 05 10, thì hỏng theo hướng ngược lại: phép đi ngược tưởng nó là octet dạng ngắn và bắt đầu lát cắt ở 05, muộn hai byte và nằm giữa các octet độ dài, không có thẻ nào cả. Kiểu gì kết quả cũng sai, chỉ khác hướng
DER bảo đảm điều gì khiến phép suy ra xuôi là chính xác?
DER bảo đảm rằng cách mã hóa độ dài là một hàm thuần của chính độ dài. X.690 điều 10.1 giới hạn DER ở dạng xác định và đòi số octet tối thiểu, nhờ đó loại bỏ hai tự do mà BER cho phép: dạng vô định, và việc đệm độ dài dạng dài bằng các octet 0 ở đầu. Dưới quy tắc đó, một độ dài nội dung dưới 128 có đúng một octet độ dài, còn mọi độ dài khác có một octet đầu cộng đúng số octet theo sau bằng số byte có nghĩa mà độ dài cần. Người gọi CmsHeaderStart vốn đã cầm ContentLen, vì ReadTlv vừa trả nó về, nên độ dài header tính được mà không cần nhìn một byte nào trong buffer
// Helper đã phát hành: suy ra header từ độ dài nội dung.
// Dưới X.690 10.1, các octet độ dài là một hàm của ContentLen
function CmsHeaderStart(const Buf: TBytes; ContentOffs, ContentLen: Integer): Integer;
var
LengthOctets, Remaining: Integer;
begin
if ContentLen< 128 then
LengthOctets:= 1 // dạng ngắn, X.690 8.1.3.4
else
begin
LengthOctets:= 1; // octet đầu, X.690 8.1.3.5
Remaining:= ContentLen;
while Remaining> 0 do
begin
Inc(LengthOctets); // một octet cho mỗi byte có nghĩa
Remaining:= Remaining shr 8;
end;
end;
Result:= ContentOffs- LengthOctets- 1;
if Result< 0 then
Result:= ContentOffs;
end;
Hai chi tiết khiến cách này an toàn chứ không chỉ nghe hợp lý. Thứ nhất, giả định đầu vào là DER được cưỡng chế từ tầng trên: TDerReader.TryReadTlvAt, thứ mà ReadTlv được dựng trên đó, từ chối dạng vô định, từ chối độ dài dạng dài có octet theo sau đầu tiên bằng 0, và từ chối một octet theo sau đơn lẻ nhỏ hơn $80. Một TLV đi tới được CmsSliceTlv đã qua hết các phép kiểm tra đó, nên một độ dài kiểu BER không tối thiểu không thể lọt tới phép suy ra rồi làm nó nói dối. Thứ hai, nhánh dự phòng cho kết quả âm giờ canh đúng đáp án thật chứ không canh một giá trị trung gian. Cũng đáng nói rằng reader vốn biết offset của thẻ từ đầu: TDerTlv mang cả Offset lẫn HeaderLength, chỉ có bề mặt ReadTlv với bốn tham số ra là làm rơi chúng. Trả chúng về sẽ là interface sạch hơn về lâu dài; bản vá đã phát hành giữ nguyên bề mặt đó và làm helper đúng theo cách riêng của nó
Vì sao các bài test timestamp vẫn qua dù bug còn đó?
Vì mọi certificate trong fixture đều đủ ngắn để dùng dạng ngắn, mà phép đi ngược lại đúng chính ở ca đó. Tests.PadesTimestamp.pas dựng certificate của người ký bằng SetLength(SignerCertDer, 32) ở một bài test và 64 ở bài khác, nội dung là một dãy byte tăng dần. Một tập certificate 32 byte mã hóa thành A0 20 còn loại 64 byte thành A0 40, mỗi cái chỉ một octet độ dài. Đi ngược từ nội dung sẽ rơi đúng vào octet đó, bit trên cùng của nó bằng 0 vì nó là octet độ dài đầu tiên và duy nhất, và helper trả lời đúng vì lý do sai. Bộ 1414 ca test xanh, CMS đã gắn timestamp parse được, validator giai đoạn 1 báo B-T, và mọi phép kiểm tra đó đều chạy trên một tập certificate mà chưa tài liệu thật nào từng chứa
Quy tắc chung mới là phần hữu ích. Mỗi khi một đường code phụ thuộc vào cách một độ dài được mã hóa, fixture phải vượt qua ranh giới mã hóa, và với DER thì điều đó nghĩa là nội dung dài hơn 127 byte, buộc phải dùng dạng dài, và lý tưởng là dài hơn 255 byte nữa, buộc phải có octet theo sau thứ hai. Kỷ luật tương tự áp cho ca còn lại trong lần soát đó, nơi việc tự kiểm chứng không nhìn ra một sai lệch DER: SET OF chưa được sắp trong signedAttrs vô hình với một vòng round-trip cùng nguồn vì một lý do giống hệt về cấu trúc, bài test chỉ thử đúng những đầu vào mà code sai và code đúng cho cùng kết quả. Đoạn phác bên dưới gọi thẳng helper cắt lát, nghĩa là phải export nó khỏi FPdfCms.pas cho bản test; cùng ranh giới đó cũng tới được qua bề mặt công khai bằng cách đưa cho BuildSignedData một certificate chuỗi ở mỗi kích thước rồi parse lại kết quả đã gắn timestamp
// Ghim ranh giới: một lát cắt qua header dạng dài phải bắt đầu ở thẻ
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;
// nội dung bắt đầu ngay sau header; lát cắt phải là cả 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;
Lần dựng lại vẫn vạch ranh giới ở đâu
AddSignatureTimestampToCms được viết cho đúng loại CMS mà BuildSignedData sinh ra, và các giới hạn của nó suy ra từ đó. Phép duyệt kỳ vọng đúng một SignerInfo và chỉ phát lại một cái đó, nên một CMS lạ có nhiều người ký sẽ quay về chỉ với một người ký; nó nhận ra tập certificates [0] tùy chọn nhưng không nhận ra tập crls [1], và một CMS mang tập đó sẽ fail ồn ào bằng exception signerInfos SET expected chứ không âm thầm cắt sai. SET unsignedAttrs mới chỉ chứa một attribute, nên quy tắc thứ tự SET OF của X.690 điều 11.6 đương nhiên được thỏa và không cần sắp xếp. Và phần đã ký thì bất động ngay từ cách dựng: tiền tố SignerInfo tính tới OCTET STRING chữ ký được sao chép nguyên văn, đó là lý do một validator tính lại digest của signedAttrs vẫn thấy đúng các byte đó trước và sau khi timestamp được thêm vào. Khi vẫn có validator từ chối tài liệu, nguyên nhân thường nằm ở chỗ khác và đáng có một danh sách kiểm tra riêng
Trình đọc DER, writer, bộ dựng CMS cùng phần tiêm timestamp này đều được phát hành dưới dạng mã nguồn Pascal kèm PDFium Delphi component, và một bug kiểu này chính là lý do cho việc đó: khi một SignedData dựng lại dài hơn chín mươi byte, bạn muốn đọc chính helper đã cắt lát và điều khoản X.690 mà nó đọc sai, chứ không phải một stack trace từ hộp đen