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

SASLprep รหัสผ่าน AES-256 ของ PDF ใน Delphi ด้วย PDFlibPas

PDF ที่เข้ารหัส AES-256 ด้วยรหัสผ่านที่ไม่ใช่ ASCII จะเปิดได้เฉพาะในโปรแกรมที่เขียนมันขึ้นมาเท่านั้น และเปิดที่อื่นไม่ได้เลย สาเหตุเกือบทุกครั้งคือขั้นตอนเตรียมที่ขาดหายไป ISO 32000-2 §7.6.4.3.3 กำหนดให้รหัสผ่านต้องผ่านการประมวลผลด้วยโปรไฟล์ SASLprep ของ stringprep ก่อนจะถูกเข้ารหัสเป็น UTF-8 และ hash PDFlibPas ไลบรารี PDF สำหรับ Delphi และ C++Builder ทำการเตรียมนี้ภายใน Encrypt, EncryptFile และ DecryptFile

นี่ไม่ใช่เรื่องรหัสผ่านผิด และไม่ใช่เรื่อง permission bit ถ้าผู้ใช้ของคุณพิมพ์รหัสผ่านที่คุณไม่เคยออกให้ กลไก retry ในบทความ การลองรหัสผ่าน PDF ที่เข้ารหัสซ้ำ คือสิ่งที่คุณต้องการ และถ้าคุณกำลังพยายามหาว่าไฟล์ที่มีอยู่แล้วบังคับใช้อะไรจริง ๆ การตรวจสอบการเข้ารหัสและสิทธิ์ ครอบคลุมเรื่องนั้น บทความนี้แคบกว่าและแปลกกว่า รหัสผ่านถูกต้อง ผู้ใช้พิมพ์ถูกต้อง แต่ไฟล์ยังคงปฏิเสธที่จะเปิดในที่อื่น

ทำไมรหัสผ่านที่ไม่ใช่ ASCII เปิดได้ในตัวอ่านหนึ่งแต่ไม่ได้ในอีกตัว

เพราะสองโปรแกรมนั้น hash byte sequence ต่างกันจากการกดแป้นพิมพ์ชุดเดียวกัน การคำนวณกุญแจ revision 6 ใน ISO 32000-2 §7.6.4.3.3 รับรหัสผ่านเป็นไบต์ UTF-8 ตัดให้เหลือ 127 ไบต์ ต่อ salt แล้วรัน hash แบบ hardened ผลลัพธ์ถูกตรวจสอบกับ entry /U และ /O ใน encryption dictionary ไม่มีสิ่งใดในลำดับนี้คลุมเครือเลย ไบต์ที่ต่างกันเพียงหนึ่งตัวตรงไหนก็ตามในข้อมูลนำเข้าจะให้ digest ที่ต่างกันโดยสิ้นเชิง การตรวจสอบล้มเหลว และตัวอ่านมีสิ่งเดียวที่มันบอกได้ คือรหัสผ่านผิด

ไบต์ต่างกันเพราะ Unicode มีหลายวิธีในการพิมพ์สิ่งที่ดูเหมือนรหัสผ่านเดียวกัน รหัสผ่านภาษาจีนอาจมาในรูปตัวอักษรแบบ precomposed จากวิธีป้อนข้อมูลหนึ่ง และมาในรูปแบบ compatibility จากอีกวิธีหนึ่ง รหัสผ่านภาษาเยอรมันหรือฝรั่งเศสที่คัดลอกมาจากโปรแกรมประมวลผลคำอาจพก NO-BREAK SPACE (U+00A0) ตรงจุดที่ผู้ใช้เชื่อว่าเป็นช่องว่างธรรมดา หรือ SOFT HYPHEN (U+00AD) ที่แสดงผลเป็นความว่างเปล่า SASLprep มีไว้เพื่อยุบทั้งหมดนี้ให้เหลือรูปแบบมาตรฐานเดียวก่อนที่ใครจะ hash อะไรเลย เพื่อให้ทุกการทำงานที่สอดคล้องตามมาตรฐานคำนวณกุญแจเดียวกันจากเจตนาเดียวกัน

SASLprep เปลี่ยนแปลงอะไรกับรหัสผ่านจริง ๆ

RFC 4013 กำหนด SASLprep เป็นโปรไฟล์ของกรอบงาน stringprep ใน RFC 3454 และมีสี่ขั้นตอนตามลำดับ ไม่ใช่การแปลงเดียว การแม็ปมาก่อน ตาราง C.1.2 ของ RFC 3454 (ช่องว่างที่ไม่ใช่ ASCII) ถูกแม็ปเป็น U+0020 และตาราง B.1 (ตัวอักษรที่มักถูกแม็ปเป็นความว่างเปล่า) ถูกลบทิ้งไปเลย ตามด้วยการ normalize เป็น Unicode NFKC ซึ่งเป็นขั้นตอนที่พับตัวอักษร compatibility และลำดับการรวมตัวอักษรเข้าด้วยกัน จากนั้นการตรวจสอบผลลัพธ์ต้องห้ามจะปฏิเสธอะไรก็ตามในตาราง C.2.1 ถึง C.9 สุดท้าย กฎ bidirectional จาก RFC 3454 มาตรา 6 จะถูกนำมาใช้กับสาย string ที่ normalize แล้ว

PDFlibPas ทำโปรไฟล์ทั้งหมดใน unit PDFlibSASLprep ซึ่งเปิดจุดเข้าใช้งานเดียว PLSASLprepPassword รับรหัสผ่านดิบ เขียนรูปแบบที่เตรียมแล้วลงใน var parameter และคืน False เมื่อรหัสผ่านต้องถูกปฏิเสธ ฟังก์ชันนี้ตั้งใจให้เป็น total ในเส้นทางปกติ รหัสผ่านที่เป็น ASCII ล้วนจะกลับมาเหมือนเดิมทุกไบต์ ดังนั้นไม่มีอะไรเปลี่ยนแปลงกับการใช้งานที่มีอยู่แล้ว

uses
  PDFlibSASLprep;

var
  Prepared: WideString;
begin
  // RFC 4013: mapping, then NFKC, then prohibited output, then the bidi rule
  PLSASLprepPassword('I' + WideChar($00AD) + 'X', Prepared);  // -> 'IX'   B.1 deletes SOFT HYPHEN
  PLSASLprepPassword('a' + WideChar($00A0) + 'b', Prepared);  // -> 'a b'  C.1.2 maps NBSP to U+0020
  PLSASLprepPassword(WideString(WideChar($00AA)), Prepared);  // -> 'a'    NFKC folds ORDINAL INDICATOR
  PLSASLprepPassword(WideString(WideChar($2168)), Prepared);  // -> 'IX'   NFKC folds ROMAN NUMERAL NINE
  PLSASLprepPassword('user', Prepared);                       // -> 'user' ASCII is never touched
end;

ความคลุมเครือของ U+200B ที่ตารางไม่ได้แก้ไว้

มี code point ตัวหนึ่งอยู่ในสองตารางของ RFC 3454 พร้อมกัน และสองตารางนั้นขัดแย้งกัน ZERO WIDTH SPACE (U+200B) อยู่ในช่วง C.1.2 ตั้งแต่ U+2000 ถึง U+200B ซึ่งกฎบอกให้แม็ปเป็น U+0020 และยังอยู่ในช่วง B.1 ตั้งแต่ U+200B ถึง U+200D ซึ่งกฎบอกให้ลบทิ้ง อ่านขั้นตอนการแม็ปตามลำดับใดลำดับหนึ่งก็จะได้ไบต์ต่างกันจากรหัสผ่านเดียวกัน a+U+200B+b เตรียมเป็น a b ภายใต้ C.1.2 และเป็น ab ภายใต้ B.1 RFC 4013 อ้างถึงทั้งสองตารางและไม่ได้บอกว่าตัวไหนชนะ นี่จึงเป็นความคลุมเครือจริงในสเปก ไม่ใช่ความผิดพลาดในการอ่าน PDFlibPas ทดสอบสมาชิกภาพของ C.1.2 ก่อน จึงแม็ป U+200B เป็นช่องว่าง ซึ่งเป็นพฤติกรรมที่การใช้งาน stringprep ที่แพร่หลายอื่น ๆ ยึดถือ การทำให้ตรงกับพวกเขาคือสิ่งเดียวที่สำคัญตรงนี้ เพราะเป้าหมายคือความสอดคล้องของไบต์กับตัวอ่านใดก็ตามที่ลูกค้าใช้อยู่

การอ่านไฟล์เก่า: เตรียมก่อน ดิบทีหลัง

การแก้ไขสร้างปัญหาความเข้ากันได้ของตัวเอง ไฟล์ AES-256 ทุกไฟล์ที่เขียนก่อนการเปลี่ยนแปลงนี้ hash รหัสผ่าน UTF-8 ดิบ ดังนั้นการทำให้ตัวอ่านเข้มงวดตามมาตรฐานอย่างเดียวจะทำให้ลูกค้าเข้าถึงคลังเก็บของตัวเองไม่ได้ PDFlibPas แก้ปัญหานี้ในฝั่งการอ่านด้วยการลองสองตัวเลือกตามลำดับ TPDFDocument.SetPassword สร้างรายการตัวเลือกที่เริ่มจากรูปแบบที่เตรียมแล้วและถอยกลับไปใช้รูปแบบดิบ และจะเพิ่มรายการที่เตรียมแล้วก็ต่อเมื่อเอกสารนั้นเป็น AES-256 จริงและสองรูปแบบต่างกัน สำหรับรหัสผ่าน ASCII ทั้งสองรูปแบบเหมือนกัน รายการมีแค่หนึ่งรายการ และต้นทุนของกลไกทั้งหมดคือการเปรียบเทียบ string ครั้งเดียว DecryptFile ทำแบบเดียวกันตามเส้นทางเขียนใหม่ AES-256 โดยตรงของมัน โดยเรียก PLDirectDecryptFileAES256 ด้วยรหัสผ่านที่เตรียมแล้วก่อน

var
  Lib: TPDFlib;
  Bytes: AnsiString;
begin
  Lib := TPDFlib.Create;
  try
    Lib.SetOrigin(1);
    Lib.DrawText(100, 100, 'saslprep roundtrip');
    // Strength 3 and 4 are the two AES-256 values; both are prepared before hashing
    Lib.Encrypt('ow' + WideChar($00AD) + 'ner', 'pa' + WideChar($00AD) + 'ss', 4,
      Lib.EncodePermissions(1, 0, 0, 0, 0, 0, 0, 1));
    Bytes := Lib.SaveToString;
  finally
    Lib.Free;
  end;

  Lib := TPDFlib.Create;
  try
    // 'pass' is what SASLprep produced and what any conforming reader computes,
    // so the plain ASCII form opens a file created with the soft-hyphen form
    if Lib.LoadFromString(Bytes, 'pass') = 1 then
      Caption := IntToStr(Lib.PageCount);
  finally
    Lib.Free;
  end;
end;

กลไก fallback มีข้อควรระวังหนึ่งอย่างที่ควรลอกไปใช้ การพยายามครั้งที่สองใน DecryptFile จะรันก็ต่อเมื่อรูปแบบที่เตรียมแล้วกับรูปแบบดิบต่างกัน และ การพยายามครั้งแรกไม่มีรหัสข้อผิดพลาดร้ายแรง ความล้มเหลวเชิงโครงสร้างหมายความว่าข้อมูลนำเข้าเสียหายหรือไม่ใช่ encryption revision ที่คุณสันนิษฐานไว้ และการลองไฟล์ที่เสียหายซ้ำด้วยรหัสผ่านอีกตัวก็แค่เผา full parse ครั้งที่สองไปกับข้อมูลนำเข้าที่เป็นภัย เหตุผลเบื้องหลังปฏิกิริยานั้นอธิบายไว้ใน บันทึกเรื่องการ parse PDF ที่ไม่น่าเชื่อถืออย่างปลอดภัย โปรดสังเกตด้วยว่าไม่มี fallback ในฝั่งเขียน และความไม่สมมาตรนั้นตั้งใจ การอ่านยอมรับประวัติศาสตร์ แต่การเขียนไม่ยอมรับ ไฟล์ AES-256 ใหม่ทุกไฟล์จะได้ไบต์ที่สอดคล้องตามมาตรฐาน

รหัสผ่านแบบไหนถูกปฏิเสธโดยตรง และ error 604 คืออะไร

SASLprep สามารถปฏิเสธรหัสผ่านได้ทั้งหมด และเมื่อมันทำเช่นนั้น การเข้ารหัสต้องล้มเหลวอย่างชัดเจน ไม่ใช่แอบแทนที่ด้วยอะไรเงียบ ๆ Encrypt และ EncryptFile เตรียมทั้งรหัสผ่านเจ้าของและรหัสผ่านผู้ใช้เมื่อ Strength เป็น 3 หรือ 4 คืนค่า 0 เมื่อถูกปฏิเสธ และตั้งค่า LastErrorCode เป็น PDFLIB_ERROR_PASSWORD_SASLPREP ซึ่งคือ 604 มีข้อมูลนำเข้าสองกลุ่มที่ทำให้เกิดสิ่งนี้ ตารางผลลัพธ์ต้องห้ามปฏิเสธตัวอักษรควบคุม (C.2.1 และ C.2.2) code point สำหรับใช้งานส่วนตัว (C.3) non-character (C.4) lone surrogate (C.5) U+FFFD (C.6) ตัวอักษรบรรยาย ideographic (C.7) และช่วงควบคุมการแสดงผลกับการแท็ก (C.8 และ C.9) แยกต่างหาก กฎ bidi ในมาตรา 6 ของ RFC 3454 จะปฏิเสธ string ใดก็ตามที่มีตัวอักษร RandALCat จากตาราง D.1 เว้นแต่ string นั้นจะทั้งเริ่มต้นและจบด้วยตัวอักษรดังกล่าว และไม่มีตัวอักษรแบบซ้ายไปขวาอยู่เลย

var
  Lib: TPDFlib;
begin
  Lib := TPDFlib.Create;
  try
    // U+0007 is a C.2.1 control character, so preparation refuses the password
    if Lib.Encrypt('owner', 'bad' + WideChar($0007), 4,
         Lib.EncodePermissions(1, 0, 0, 0, 0, 0, 0, 1)) = 0 then
    begin
      if Lib.LastErrorCode = PDFLIB_ERROR_PASSWORD_SASLPREP then  // 604
        ShowMessage('The password contains characters that PDF encryption does not permit.');
    end;
  finally
    Lib.Free;
  end;
end;

กฎ bidi นั้นแหละที่จะทำให้ทีมซัพพอร์ตของคุณประหลาดใจ รหัสผ่านภาษาอาหรับหรือฮีบรูที่ลงท้ายด้วยตัวเลขแบบตะวันตก หรือรหัสที่มีตัวอักษรละตินหลงเข้ามาตรงกลาง จะถูกปฏิเสธตามสเปกแม้ว่ามันจะดูสมเหตุสมผลอย่างยิ่งในช่องกรอกข้อมูล ควรแสดง 604 เป็นข้อความเกี่ยวกับตัวอักษรในรหัสผ่าน ไม่ใช่ความล้มเหลวในการเข้ารหัสทั่วไป ไม่เช่นนั้นจะมีคนเสียเวลาช่วงบ่ายทั้งวันไปกับการหาบั๊กในการคำนวณกุญแจของคุณ

ขอบเขตที่ตรงไปตรงมา: NFKC, LCat โดยประมาณ และกับดัก Delphi หนึ่งจุด

สองส่วนของการทำงานเป็นการประมาณ และทั้งสองอย่างควรถูกพูดถึงอย่างตรงไปตรงมามากกว่าจะฝังไว้ การ normalize แบบ NFKC ทำผ่าน Windows API NormalizeString ที่โหลดแบบไดนามิกจาก Normaliz.dll เมื่อไลบรารีนั้นไม่พร้อมใช้งาน สาย string ที่แม็ปแล้วจะถูกใช้โดยไม่ normalize ซึ่งหมายความว่าขั้นตอนการแม็ปและการห้ามยังคงทำงาน แต่การพับ compatibility จะไม่ทำ ในทางปฏิบัติ DLL นี้มาพร้อมกับทุก Windows ตั้งแต่ Vista เป็นต้นมา ดังนั้นเส้นทางแบบลดคุณภาพจึงเป็นเรื่องของยุคก่อน Vista และที่ไม่ใช่ Windows มากกว่าจะเป็นเรื่องจริงในปัจจุบัน แต่รหัสผ่านที่พึ่งพาการพับแบบ NFKC จะให้ไบต์ต่างออกไปในสถานการณ์นั้น และนั่นคือความต่างจริง แม้จะห่างไกลก็ตาม การตรวจสอบ bidi คือการประมาณอย่างที่สอง การตรวจจับตัวอักษร LCat ใช้ช่วงตัวอักษรทั่วไปแทนตาราง D.2 ฉบับเต็มของ RFC 3454 และทิศทางของความคลาดเคลื่อนนั้นคือสิ่งที่ทำให้มันยอมรับได้ ตัวอักษร LCat ที่ตรวจไม่พบจะทำให้กฎ bidi ผ่านในกรณีที่มาตรฐานเข้มงวดกว่าจะปฏิเสธเท่านั้น ไม่มีทางกลับกัน และไม่มีทางแตะขั้นตอนการแม็ปหรือ normalize เลย ดังนั้นลำดับไบต์ที่เตรียมแล้วของรหัสผ่านที่ถูกยอมรับจะไม่เปลี่ยนแปลง ความเสี่ยงที่เหลืออยู่จึงเป็นความคลาดเคลื่อนเชิงนโยบายมากกว่าความคลาดเคลื่อนเชิงไบต์ รหัสผ่านภาษาที่แปลกออกไปซึ่งการทำงานที่เข้มงวดกว่าจะปฏิเสธไปเลย รหัสผ่านทุกตัวที่ทั้งสองฝ่ายยอมรับจะ hash ออกมาเหมือนกัน ซึ่งเป็นคุณสมบัติที่ interoperability พึ่งพาจริง ๆ

สุดท้าย กับดักไวยากรณ์ Delphi ที่เสียเวลาหนึ่งชั่วโมงถ้าคุณไม่เคยเจอมาก่อน เมื่อฟังก์ชันคืนค่าเป็น procedural type การกำหนดค่าโดยไม่มีวงเล็บจะไม่เรียกมัน compiler อ่าน Proc := GetNormalizeProc; เป็นการเอา address ของ GetNormalizeProc เอง แล้วรายงาน E2009 พร้อมคำบ่นที่ไม่ช่วยอะไรว่า calling convention ต่างกัน เพราะ accessor ใช้ convention เริ่มต้น ในขณะที่ API type ที่นำเข้ามาคือ stdcall วงเล็บว่างเปล่านั้นบังคับ

type
  TNormalizeString = function(NormForm: Integer; SrcString: PWideChar; SrcLength: Integer;
    DstString: PWideChar; DstLength: Integer): Integer; stdcall;

function GetNormalizeProc: TNormalizeString;   // loads Normaliz.dll on first use
...
var
  Proc: TNormalizeString;
begin
  // Proc := GetNormalizeProc;   // E2009: reads as @GetNormalizeProc, conventions differ
  Proc := GetNormalizeProc();    // correct: calls the accessor and assigns its result
  if not Assigned(Proc) then
    Exit;                        // no NFKC available, mapped string is used as-is
end;

การเตรียมรหัสผ่านเป็นหนึ่งในรายละเอียดที่ไม่เคยปรากฏในรายการฟีเจอร์เลย แต่ตัดสินว่าเอกสารที่เข้ารหัสจะรอดจากการเจอลูกค้าในภูมิภาคอื่นได้หรือไม่ จุดเข้าใช้งาน Encrypt, EncryptFile, DecryptFile และ SetPassword ที่กล่าวถึงในบทความนี้เป็นส่วนหนึ่งของ losLab PDF Developer Library Pascal Edition สำหรับ Delphi และ C++Builder ที่หน้าผลิตภัณฑ์มีเอกสารอ้างอิงการเข้ารหัสฉบับเต็มและตาราง error-code แบบครบถ้วน