บทความเทคนิค

ห้ามเดินย้อนกลับ length octet ของ DER: PDFium Component

PDFium Component หาตำแหน่งเริ่มต้นของโครงสร้าง CMS ที่ซ้อนกันจากความยาวเนื้อหาของมัน ไม่เคยเดินย้อนกลับบน length octet เพราะ byte ที่อยู่ก่อนเนื้อหาทันทีคือ length octet ตัวสุดท้าย และไม่ได้บอกอะไรเลยว่ามีกี่ตัวอยู่ข้างหน้า CmsHeaderStart ใน FPdfCms.pas จึงหา header length จาก ContentLen แทน ซึ่ง DER ทำให้ค่านั้นแม่นตรงพอดี และนั่นคือสิ่งที่กันไม่ให้ AddSignatureTimestampToCms ทำลายทุก CMS ที่ชุด certificate ของมันยาวกว่า 127 byte

ฉากของเรื่องคือการอัปเกรดเป็น PAdES B-T attribute signature-time-stamp ซึ่ง 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 และตามนิยามแล้วมันเพิ่มได้หลังค่าลายเซ็นมีอยู่แล้วเท่านั้น เพราะ timestamp token ถูกคำนวณบนค่านั้น ดังนั้นตอนที่ token มาถึง CMS ก็สร้างเสร็จและเซ็นเสร็จแล้ว การเพิ่ม attribute หนึ่งตัวเปลี่ยนความยาวของ SignerInfo ซึ่งเปลี่ยนความยาวของ SET signerInfos แล้วก็ของ SignedData แล้วก็ของ [0] EXPLICIT wrapper แล้วก็ของ ContentInfo ชั้นนอก ทุก header ที่ครอบอยู่ต้องถูก emit ใหม่ และทุกอย่างที่ไม่ได้อยู่บนเส้นทางนั้นต้องถูกพาข้ามไปแบบ byte ต่อ byte คำแนะนำแบบเดินทีละขั้นเรื่อง B-LT และ B-LTAครอบคลุมว่า token ให้อะไรกับคุณ บทความนี้พูดถึงสี่ byte ที่อยู่หน้าชุด certificate ซึ่งการสร้างใหม่ทำผิดอยู่เรื่อย ๆ

ทำไมการเพิ่ม timestamp ถึงต้องรู้ tag offset ของพี่น้อง

เพราะการสร้างใหม่ใช้พี่น้องสี่ตัวของ SET signerInfos ซ้ำแบบ verbatim และ reader รายงานว่าเนื้อหาของมันอยู่ตรงไหน ไม่ใช่ tag ของมันอยู่ตรงไหน TDerReader.ReadTlv คืน tag byte, offset ของเนื้อหา, ความยาวเนื้อหา และ offset ของ TLV ถัดไป นั่นเป็นพื้นผิวที่ถูกต้องสำหรับการลงไปในโครงสร้าง แต่ถ้าจะคัดลอกทั้ง element คุณต้องรู้ octet ที่ tag ของมันนั่งอยู่ และสิ่งที่ผู้เรียกถืออยู่มีแค่ ContentOffs CmsSliceTlv มีอยู่เพื่อเชื่อมช่องว่างนั้น: เมื่อให้ offset และความยาวของเนื้อหา มันคืน tag, length octet และเนื้อหาเป็นบัฟเฟอร์เดียว และ AddSignatureTimestampToCms เรียกมันสำหรับ OID contentType, INTEGER version, SET digestAlgorithms, SEQUENCE encapContentInfo และชุด certificates [0] เมื่อมี

// ภายใน AddSignatureTimestampToCms: ลงไปข้างใน แล้ว slice พี่น้องแบบ 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;
// 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 octet + เนื้อหา
  R.Position:= CN;
end;

ในบรรดาห้าชิ้นนั้น สี่ชิ้นเล็กจิ๋ว: OID สิบเอ็ด byte, INTEGER สาม byte, ชุด digest algorithm สิบเจ็ด byte และ encapContentInfo แบบ detached สิบสาม byte ชุด certificate คือชิ้นที่พกใบรับรองของผู้เซ็นกับ chain ของมัน และใบรับรอง X.509 จริง ๆ นั้นยาวอย่างน้อยหลายร้อย byte ชุด certificate จึงเป็นชิ้นเดียวที่ length octet ของมันอยู่ในรูปยาวเสมอ และมันคือชิ้นที่ helper ตัวเก่าหาตำแหน่งไม่เจอ

สิ่งที่การเพิ่ม timestamp แบบ PAdES B-T สร้างใหม่ใน CMS: CmsSliceTlv คัดลอก contentType, version, digestAlgorithms, encapContentInfo และชุด certificates แบบ byte ต่อ byte, ชุด certificate เป็นชิ้นเดียวที่ยาวพอจะออกจาก length รูปสั้น และทุก header ที่ครอบอยู่ตั้งแต่ SignerInfo ขึ้นไปถึง ContentInfo จะถูก emit ใหม่
ส่วนที่ถูกเซ็นไม่ถูกแตะโดยโครงสร้างเพราะคำนำหน้าของ SignerInfo จนถึง signature OCTET STRING ถูกคัดลอกแบบ verbatim validator ที่คำนวณ digest ของ signedAttrs ใหม่จึงเห็น byte ที่เหมือนกันทั้งก่อนและหลัง timestamp ลงไป

ทำไม length octet ของ DER ถึงเดินย้อนกลับไม่ได้

เพราะจำนวนของ length octet ถูกเก็บไว้ในตัวแรกของมัน และเมื่ออ่านจากเนื้อหาย้อนกลับ คุณก็เจอตัวสุดท้ายก่อน X.690 clause 8.1.3.4 นิยามรูปสั้น: octet ตัวเดียว bit 8 เป็นศูนย์ bit 7 ถึง 1 เก็บความยาวตั้งแต่ 0 ถึง 127 clause 8.1.3.5 นิยามรูปยาว: octet ตัวแรกที่ตั้ง bit 8 โดย bit 7 ถึง 1 ของมันบอกจำนวน octet ที่ตามมา แล้วตามด้วย octet เหล่านั้นที่พกความยาวเป็น unsigned big-endian integer ไม่มีอะไรในกฎนี้ทำเครื่องหมายว่า octet ตัวไหนเป็นตัวที่ตามมา bit 8 ของมันก็เป็น bit ของขนาดเหมือน bit อื่น ๆ การเดินย้อนกลับที่ทดสอบบิตบนของ Buf[ContentOffs- 1] จึงเป็นการทดสอบ bit ของข้อมูล แล้วเอาบิตเจ็ดตัวล่างของมันไปอ่านเป็นจำนวน

// helper ตัวเก่า ที่ได้มาแค่ offset ของเนื้อหา
function CmsHeaderStart(const Buf: TBytes; ContentOffs: Integer): Integer;
var
  P, LenByte, LongLen: Integer;
begin
  P:= ContentOffs- 1;            // ตกลงบน length octet ตัวสุดท้าย
  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 byte:  A0 82 05 DC
//   Buf[ContentOffs- 1]= $DC  -> ตั้ง bit 8, $DC and $7F= 92
//   Result= ContentOffs- 94     (tag อยู่ที่ ContentOffs- 4)

ลองดู header ของชุด certificate ที่บรรจุใบรับรอง 1500 byte คือ A0 82 05 DC การเดินไปตกที่ DC เห็นว่าบิตบนถูกตั้ง ก็ดึง 92 ออกจากเจ็ดบิตล่างแล้วรายงานว่า tag อยู่ก่อนเนื้อหา 94 byte ทั้งที่มันอยู่ก่อนแค่ 4 byte ใน SignedData ที่สร้างโดย BuildSignedData เนื้อหาของชุด certificate อยู่ห่างจากต้น CMS แค่ไม่กี่สิบ byte offset ที่คำนวณได้จึงไม่ใช่แค่เร็วเกินไปแต่ติดลบ และโค้ดเก่าก็กัน ContentOffs- 1 ไม่ให้ต่ำกว่าศูนย์ ไม่ได้กันผลลัพธ์สุดท้ายของมัน จากนั้น CmsSliceTlv ก็ตัดชิ้นที่ยาวกว่าตัว element เก้าสิบกว่า byte โดยเริ่มก่อนหน้าบัฟเฟอร์ และ SignedData ที่สร้างใหม่ก็พกชิ้นนั้นไว้ตรงที่ชุด certificate ของมันควรอยู่ ความยาวสาม octet ที่ octet สุดท้ายบังเอิญต่ำกว่า $80 เช่น A0 82 05 10 ล้มเหลวอีกทาง: การเดินเอามันไปเป็น octet รูปสั้นแล้วเริ่ม slice ที่ 05 ช้าไปสอง byte และอยู่ภายใน length octet โดยไม่มี tag เลย ผลลัพธ์ผิดทั้งสองทาง ต่างกันแค่ทิศ

ทำไม length octet ของ DER ถึงเดินย้อนกลับไม่ได้: การอ่าน A0 82 05 DC จากท้ายไปตกที่ octet สุดท้าย DC ซึ่งบิตบนที่ถูกตั้งให้จำนวนปลอม ๆ 92 แล้ววาง tag เร็วเกินไป 94 octet ขณะที่ A0 82 05 10 ล้มเหลวอีกทางและเริ่ม slice ช้าไปสอง octet ภายใน length octet
CmsHeaderStart ตัวเก่ากันการลบระหว่างทางแทนที่จะกันผลลัพธ์สุดท้าย slice จึงเริ่มก่อนหน้าบัฟเฟอร์ได้ด้วยซ้ำ และ SignedData ที่สร้างใหม่ก็พกชิ้นนั้นไว้ตรงที่ชุด certificate ของมันควรอยู่

DER รับประกันอะไรที่ทำให้การหาข้างหน้าแม่นตรง

DER รับประกันว่าการ encode ความยาวเป็นฟังก์ชันแท้ของความยาวเท่านั้น X.690 clause 10.1 จำกัด DER ไว้ที่รูป definite และกำหนดให้ใช้จำนวน octet น้อยที่สุด ซึ่งตัดอิสรภาพสองอย่างที่ BER ยอมให้ออกไป: รูป indefinite และการเติม octet ศูนย์นำหน้าให้ความยาวรูปยาว ภายใต้กฎนั้นความยาวเนื้อหาที่ต่ำกว่า 128 มี length octet หนึ่งตัวพอดี และความยาวอื่น ๆ มี octet ตัวแรกหนึ่งตัวบวก octet ที่ตามมาเท่ากับจำนวน byte ที่มีความหมายของความยาวนั้นพอดี ผู้เรียก CmsHeaderStart ถือ ContentLen อยู่แล้ว เพราะ ReadTlv เพิ่งคืนมันมา header length จึงคำนวณได้โดยไม่ต้องดู byte สักตัวของบัฟเฟอร์

การหาข้างหน้าที่ DER รับประกัน: ความยาวเนื้อหา 127 encode เป็น A0 7F, 128 เป็น A0 81 80, 255 เป็น A0 81 FF, 256 เป็น A0 82 01 00 และ 1500 เป็น A0 82 05 DC ดังนั้น header length จึงตามมาจาก ContentLen เพียงอย่างเดียว และ TryReadTlvAt ก็ปฏิเสธรูป BER ที่ไม่น้อยที่สุดทุกแบบไปแล้ว
fixture ที่สร้างจากใบรับรองขนาด 32 byte และ 64 byte ยังอยู่ในรูปสั้นซึ่งการเดินย้อนกลับตอบถูกด้วยเหตุผลที่ผิด นั่นคือเหตุผลที่ชุดเทสต์เส้นแบ่งตอนนี้ข้าม 127, 128, 255 และ 256 byte
// helper ที่ออกใช้งานจริง: หา header จากความยาวเนื้อหา
// ภายใต้ X.690 10.1 length octet เป็นฟังก์ชันของ 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;                // octet ตัวแรก X.690 8.1.3.5
    Remaining:= ContentLen;
    while Remaining> 0 do
    begin
      Inc(LengthOctets);             // หนึ่งตัวต่อหนึ่ง byte ที่มีความหมาย
      Remaining:= Remaining shr 8;
    end;
  end;
  Result:= ContentOffs- LengthOctets- 1;
  if Result< 0 then
    Result:= ContentOffs;
end;

มีสองรายละเอียดที่ทำให้วิธีนี้ปลอดภัย ไม่ใช่แค่ดูสมเหตุสมผล ข้อแรก สมมติฐานที่ว่า input เป็น DER ถูกบังคับใช้ตั้งแต่ต้นน้ำ: TDerReader.TryReadTlvAt ซึ่ง ReadTlv สร้างอยู่บนนั้น ปฏิเสธรูป indefinite, ปฏิเสธความยาวรูปยาวที่ octet ที่ตามมาของมันเป็นศูนย์ และปฏิเสธ octet ที่ตามมาเพียงตัวเดียวที่ต่ำกว่า $80 TLV ที่ไปถึง CmsSliceTlv ผ่านการตรวจเหล่านั้นมาแล้ว ความยาวแบบ BER ที่ไม่น้อยที่สุดจึงไปถึงการหาค่านี้และทำให้มันโกหกไม่ได้ ข้อสอง fallback สำหรับผลลัพธ์ติดลบตอนนี้กันคำตอบจริง ไม่ได้กันค่าระหว่างทาง คุ้มที่จะบอกว่า reader รู้ tag offset มาตลอด: TDerTlv พกทั้ง Offset และ HeaderLength และมีแค่พื้นผิว ReadTlv แบบสี่ out-parameter ที่ทิ้งมันไป การคืนค่าพวกมันกลับจะเป็นอินเทอร์เฟซที่สะอาดกว่าในระยะยาว การแก้ที่ออกใช้จริงรักษาพื้นผิวนั้นไว้ครบและทำให้ helper ถูกต้องในเงื่อนไขของตัวเอง

ทำไมเทสต์ timestamp ถึงผ่านทั้งที่ bug ยังอยู่

เพราะใบรับรองใน fixture ทุกตัวสั้นพอที่จะใช้รูปสั้น และการเดินย้อนกลับก็ถูกต้องสำหรับเคสนั้นเป๊ะ ๆ Tests.PadesTimestamp.pas สร้างใบรับรองของผู้เซ็นด้วย SetLength(SignerCertDer, 32) ในเทสต์หนึ่ง และ 64 ในอีกเทสต์หนึ่ง เติมด้วย byte ramp ชุด certificate ขนาด 32 byte encode เป็น A0 20 และขนาด 64 byte เป็น A0 40 อย่างละหนึ่ง length octet การเดินย้อนกลับจากเนื้อหาจึงตกบน octet ตัวนั้น บิตบนของมันเป็นศูนย์เพราะมันเป็น length octet ตัวแรกและตัวเดียว และ helper ก็ตอบถูกด้วยเหตุผลที่ผิด ชุดเทสต์ 1414 เคสเขียวทั้งหมด CMS ที่ใส่ timestamp แล้ว parse ผ่าน validator ระยะที่ 1 รายงาน B-T และการตรวจทุกข้อนั้นรันกับชุด certificate ที่ไม่มีเอกสารจริงฉบับไหนเคยมี

กฎทั่วไปคือส่วนที่ได้ประโยชน์ ทุกครั้งที่เส้นทางโค้ดขึ้นกับว่าความยาวถูก encode อย่างไร fixture ต้องข้ามเส้นแบ่งของการ encode และสำหรับ DER นั่นหมายถึงเนื้อหาที่ยาวกว่า 127 byte ซึ่งบังคับให้เป็นรูปยาว และที่ดียาวกว่า 255 byte ด้วย ซึ่งบังคับให้มี octet ที่ตามมาตัวที่สอง วินัยเดียวกันนี้ใช้กับอีกเคสในการรีวิวครั้งนั้นที่การตรวจสอบตัวเองมองไม่เห็นความเบี่ยงเบนของ DER: SET OF ที่ไม่ได้เรียงใน signedAttrs มองไม่เห็นด้วยการ round trip จากต้นทางเดียวกัน ด้วยเหตุผลเชิงโครงสร้างที่เหมือนกัน คือเทสต์ไปแตะเฉพาะ input ที่โค้ดผิดกับโค้ดถูกให้คำตอบตรงกัน ร่างด้านล่างเรียก helper ตัดชิ้นโดยตรง ซึ่งหมายถึงต้อง export มันจาก FPdfCms.pas สำหรับบิลด์เทสต์ ขอบเขตเดียวกันนี้ไปถึงได้ผ่านพื้นผิวสาธารณะด้วยการป้อนใบรับรองใน chain แต่ละขนาดให้ BuildSignedData แล้ว parse ผลลัพธ์ที่ใส่ timestamp ใหม่

// ตอกเส้นแบ่ง: slice ผ่าน header รูปยาวต้องเริ่มที่ 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;
      // เนื้อหาเริ่มต่อจาก header ทันที; slice ต้องเป็นทั้ง 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 emit และขอบเขตของมันก็ตามมาจากตรงนั้น การเดินคาดหวัง SignerInfo ตัวเดียวและ emit ใหม่แค่ตัวนั้น CMS ของคนอื่นที่มีผู้เซ็นหลายคนจึงจะกลับมาเหลือผู้เซ็นคนเดียว; มันรู้จักชุด certificates [0] ที่เป็นออปชัน แต่ไม่รู้จักชุด crls [1] และ CMS ที่พกมันมาจะล้มเหลวเสียงดังด้วย exception signerInfos SET expected แทนที่จะตัด slice ผิดอย่างเงียบ ๆ unsignedAttrs ตัวใหม่พก attribute หนึ่งตัว กฎการเรียงของ SET OF ใน X.690 clause 11.6 จึงเป็นจริงแบบชัดเจนอยู่แล้วและไม่ต้อง sort และส่วนที่ถูกเซ็นไม่ถูกแตะโดยโครงสร้าง: คำนำหน้าของ SignerInfo จนถึง signature OCTET STRING ถูกคัดลอกแบบ verbatim นั่นคือเหตุผลที่ validator ซึ่งคำนวณ digest ของ signedAttrs ใหม่เห็น byte เดิมทั้งก่อนและหลังการเพิ่ม timestamp เมื่อตัวหนึ่งยังปฏิเสธเอกสาร สาเหตุมักอยู่ที่อื่นและคุ้มที่จะมี checklist ของตัวเอง

DER reader, writer, CMS builder และการฉีด timestamp นี้ ล้วนออกมาเป็นซอร์ส Pascal พร้อมกับคอมโพเนนต์ PDFium สำหรับ Delphi และ bug รูปร่างแบบนี้ก็เป็นเหตุผลสนับสนุนเรื่องนั้น: เมื่อ SignedData ที่สร้างใหม่ยาวเกินไปเก้าสิบ byte คุณอยากอ่าน helper ที่ตัดชิ้นนั้นกับ clause ของ X.690 ที่มันอ่านผิด ไม่อยากได้ stack trace จากกล่องดำ