מאמר טכני

הצפנת תעודות ב-PDF בדלפי: RSA-OAEP ו-ECDH

HotPDF מצפינה PDF עבור מחזיקי תעודות ספציפיים דרך מטפל האבטחה של המפתח הציבורי ב-ISO 32000: EnablePubKeyEncryption לוקח seed אקראי בן 20 בתים, וכל נמען מקבל מעטפת CMS משלו, שנבנית על ידי AddPubKeyRecipientCertificate עבור מפתחות RSA (העברת מפתח RSA-OAEP) או AddPubKeyAgreementRecipientWithSecret עבור מפתחות עקומה אליפטית (ECDH על P-256, P-384, P-521, X25519 או X448). אף אחד לא משתף סיסמה; מי שמחזיק מפתח פרטי תואם פותח את הקובץ

התרחיש הוא תמיד גרסה כלשהי של אותו סיפור. חבילת ביקורת רבעונית הולכת אל שלושה סוקרים חיצוניים, המשפטים רוצים שכל אחד מהם יקרא אותה, רק אחד מהם רשאי להדפיס אותה, ואף אחד לא רוצה סיסמה יושבת בשרשור מייל לצד הקובץ המצורף. הצפנת סיסמה לא יכולה לבטא את זה. הצפנת תעודות יכולה, כי כל נמען פותח את המסמך עם מפתח שכבר מחזיק בו, וכל נמען יכול לשאת סט הרשאות שונה בתוך המעטפת שלו

מה שונה בין הצפנת PDF מבוססת תעודות לסיסמה?

PDF מוצפן מפתח-ציבורי גוזר את מפתח הקובץ שלו מ-seed אקראי בתוספת הבתים המדויקים של כל מעטפת נמען, ולא משום דבר שאדם מקליד. המטפל מתואר ב-ISO 32000-1 §7.6.4 (§7.6.5 ב-ISO 32000-2), והמעטפות הן מבני CMS EnvelopedData כפי שהוגדרו ב-RFC 5652. HotPDF כותבת /Filter /Adobe.PubSec עם /SubFilter /adbe.pkcs7.s5; עבור AES-256 זה אומר /V 5 ורשומת /DefaultCryptFilter תחת /CF עם /CFM /AESV3, ומערך ה-/Recipients חי בתוך ה-crypt filter הזה. כל מעטפת מצפינה 24 בתים: ה-seed בן 20 הבתים ואחריו מילת ההרשאות בת 32 הסיביות של אותו נמען. ערך ה-/P במילון ההצפנה הוא רק placeholder, כי ההרשאות האמיתיות נוסעות בתוך כל מעטפת. בזמן טעינה קורא פותח מעטפת אחת, משחזר את ה-seed, ומחשב hash של ה-seed יחד עם כל מעטפת בסדר ה-/Recipients (SHA-256 עבור AES-256, SHA-1 עבור הצופנים הישנים) כדי לבנות מחדש את מפתח הקובץ. אם עדיין מתלבטים בין המודל הזה לסיסמאות רגילות, המדריך להצפנת סיסמה AES-256 ודגלי הרשאות מכסה את הצד השני של הפשרה הזאת

דיאגרמת הצפנת מפתח-ציבורי של HotPDF: EnablePubKeyEncryption קובע seed של 20 בתים, כל מעטפת CMS EnvelopedData מצפינה את 20 הבתים האלה בתוספת מילת הרשאות אחת בת 32 סיביות בתוך /Filter /Adobe.PubSec עם /SubFilter /adbe.pkcs7.s5 ו-/CFM /AESV3, והקורא פותח מעטפת אחת, משחזר את ה-seed ומחשב hash שלו עם כל רשומה ב-/Recipients בסדר המערך כדי לבנות מחדש את מפתח הקובץ
ערך ה-/P במילון ההצפנה הוא רק placeholder כי ההרשאות האמיתיות נוסעות בתוך כל מעטפת, ושום דבר במורד הזרם לא רשאי לסדר מחדש או לקודד מחדש את המערך שה-digest רץ עליו

כתיבת נמעני RSA עם EnablePubKeyEncryption

עבור תעודות RSA, קוראים ל-EnablePubKeyEncryption עם aes256, ואז קוראים ל-AddPubKeyRecipientCertificate פעם אחת לכל תעודה מקודדת-DER לפני BeginDoc. ה-helper בונה מעטפת RSAES-OAEP בתהליך עצמו עם ערכי THPDFRSAOAEPHash עבור digest ה-OAEP ו-digest ה-MGF1 (rohSHA256, rohSHA384 או rohSHA512), והוא מצפין את תוכן המעטפת עם AES-256-CBC

uses
  System.SysUtils, System.IOUtils, HPDFDoc, HPDFCrypt, HPDFRSA;

procedure WriteAuditPack(const OutFile: string);
var
  Pdf: THotPDF;
  Seed: AnsiString;
begin
  SetLength(Seed, 20);                      // בדיוק 20 בתים, גם עבור AES-256
  AESGenerateRandomBytes(@Seed[1], Length(Seed));
  Pdf := THotPDF.Create(nil);
  try
    Pdf.AutoLaunch := False;
    Pdf.FileName := OutFile;
    Pdf.EnablePubKeyEncryption(Seed, aes256, True);   // סוג מפתח ברירת המחדל הוא aes128
    // סוקר A רשאי להדפיס; סוקר B רשאי רק לקרוא ולחלץ
    Pdf.AddPubKeyRecipientCertificate(TFile.ReadAllBytes('reviewer-a.cer'),
      [prPrint, prPrint12bit, prExtractContent], rohSHA256, rohSHA256);
    Pdf.AddPubKeyRecipientCertificate(TFile.ReadAllBytes('reviewer-b.cer'),
      [prExtractContent]);
    Pdf.BeginDoc;
    Pdf.CurrentPage.SetFont('Arial', [], 11);
    Pdf.CurrentPage.TextOut(72, 720, 0, 'Q3 audit pack');
    Pdf.EndDoc;
  finally
    Pdf.Free;
  end;
end;

שלושה פרטים ברשימה הזאת נושאים את העומס. ראשית, אורך ה-seed קבוע על 20 בתים עבור כל סוג מפתח, AES-256 כלול; EnablePubKeyEncryption מעלה חריגה על כל אורך אחר. שנית, EnablePubKeyEncryption ברירת המחדל שלו aes128, ושני ה-helpers של התעודות מסרבים לרוץ אלא אם סוג המפתח הוא aes256, ולכן שכחת הארגומנט השני משיגה לכם את החריגה "מעטפות תעודה דורשות aes256". הצופנים הישנים (k40, k128, aes128) עדיין עובדים, אבל רק דרך AddPubKeyRecipient עם מעטפת שבניתם בעצמכם. שלישית, הצפנת מפתח-ציבורי AES-256 היא תכונה של PDF 2.0, ולכן HotPDF מעלה את גרסת המסמך אל 2.0 אוטומטית. עם StrictVersionLock מוגדר על גרסה נמוכה יותר, EnablePubKeyEncryption חוזר בלי להפעיל דבר, והכשל מופיע רק בשורה הבאה כ"קראו ל-EnablePubKeyEncryption קודם". החלפת הצפנה במהלך עדכון מצטבר מעלה EInvalidOpException מיד

הוספת נמעני ECDH: P-256, P-384, P-521, X25519 ו-X448

עבור תעודות עקומה אליפטית, AddPubKeyAgreementRecipientWithSecret כותב נמען הסכמת-מפתח של CMS (KeyAgreeRecipientInfo, מבנה ה-KARI מ-RFC 5753, עם הפרופיל של X25519 ו-X448 מ-RFC 8418) ומחשב את סוד ה-ECDH המשותף בתהליך עצמו. בוחרים את העקומה עם ערך THPDFPubKeyAgreementScheme: pkasECDHP256, pkasECDHP384, pkasECDHP521, pkasX25519 או pkasX448. ה-scheme חייב להתאים למפתח בתעודה, אחרת הקריאה מעלה "מפתח התעודה אינו תואם ל-scheme ההסכמה המבוקש". מתחת למכסה המנוע, כל מעטפת מקבלת UKM אקראי טרי בן 32 בתים, מפתח-הצפנת-מפתח הנגזר עם ה-KDF של stdDH (SHA-256 עבור P-256 ו-X25519, SHA-384 עבור P-384, SHA-512 עבור P-521 ו-X448), ו-key wrap של AES-256 כהגדרתו ב-RFC 3394. הסוד המשותף עצמו מגיע מקוד עקומה ב-Pascal טהור, בלי ספק crypto של הפלטפורמה בתמונה; המאמר על אריתמטיקה של עקומות NIST ב-Pascal טהור מסביר איך השכבה הזאת נבנתה ואומתה. עבור העקומות המונטגומריות אפשר לייצר את כל זוג המפתחות האפמרלי באופן מקומי:

uses
  System.SysUtils, System.IOUtils, HPDFDoc, HPDFCrypt, HPDFPubSec,
  HPDFKeyAgreement;

procedure AddLegalRecipient(Pdf: THotPDF);
var
  Scalar, OriginatorPublic: TBytes;
begin
  // Scalar אפמרלי טרי לכל מעטפת; ה-clamping קורה בתוך הסולם
  SetLength(Scalar, 32);
  AESGenerateRandomBytes(@Scalar[0], Length(Scalar));
  try
    OriginatorPublic := HPDFX25519PublicFromScalar(Scalar);
    Pdf.AddPubKeyAgreementRecipientWithSecret(
      TFile.ReadAllBytes('legal-x25519.cer'),
      [prPrint, prExtractContent], pkasX25519,
      OriginatorPublic, Scalar,
      []);   // OwnPublicPoint: משמעותי רק עבור העקומות של NIST
  finally
    HPDFSecureClearBytes(Scalar);
  end;
end;

עקומות NIST דורשות יותר מהקורא ל-API. HotPDF משלוחה helpers של מפתח-ציבורי רק עבור X25519 ו-X448 (HPDFX25519PublicFromScalar, HPDFX448PublicFromScalar), ולכן עבור P-256, P-384 ו-P-521 אתם מייצרים את זוג המפתחות האפמרלי בעזרת הכלים שלכם ומעבירים scalar big-endian בגודל השדה בדיוק (32, 48 או 66 בתים) בתוספת הנקודה הלא דחוסה המתאימה 0x04||X||Y כ-OriginatorPublicKey. HotPDF מאמתת את נקודת הנמען מול משוואת העקומה, אבל היא לא יכולה לבדוק שמפתח ה-originator הציבורי שלכם באמת שייך ל-scalar שלכם. חצאים לא תואמים עדיין מניבים מעטפת תקינה לגמרי שאף נמען לא יכול לפתוח, וזו הסיבה שטעינת round-trip שייכת לסוויטת הבדיקות שלכם, ולא רק בדיקת גודל קובץ

דיאגרמת הסכמת ECDH של HotPDF: AddPubKeyAgreementRecipientWithSecret גוזר את הסוד המשותף בקוד עקומה ב-Pascal טהור, מערבב UKM טרי של 32 בתים דרך ה-KDF של stdDH עם SHA-256 עבור P-256 ו-X25519, SHA-384 עבור P-384, SHA-512 עבור P-521 ו-X448, ואז עוטף את מפתח התוכן ב-key wrap של AES-256 מ-RFC 3394 כדי לבנות את מעטפת KeyAgreeRecipientInfo
ערך ה-scheme מ-pkasECDHP256 ועד pkasX448 חייב להתאים למפתח התעודה, וחצאי scalar ונקודה ציבורית לא תואמים עדיין מניבים מעטפת תקינה שאף נמען לא יכול לפתוח

למה הסדר של /Recipients חשוב?

הסדר של /Recipients חשוב כי מפתח הקובץ הוא digest על ה-seed וכל מעטפת בסדר המערך, ולכן הכותב והקורא חייבים לחשב hash על אותם בתים באותו רצף. HotPDF שומרת מעטפות בסדר שבו הוספתם אותן וכותבת אותן ללא שינוי, כלומר אפשר להוסיף נמענים בכל סדר שתרצו, אבל שום דבר במורד הזרם לא רשאי לסדר, לקודד מחדש או "לסדר" את המערך הזה. רוב הבאגים האמיתיים באזור הזה היו וריאציה על אותו נושא, שבו שני צדדים חישבו hash על בתים שונים במקצת:

  • אחסון מערכים דינמיים ב-TList דרך Add משאיר רק מצביע גולמי בזמן שמונה ההפניות נשאר אצל המשתנה המקומי. ה-SetLength הבא משחרר את החוצץ ועשוי לעשות בו שימוש חוזר, ולכן כל משבצת הסתיימה כ-alias של המעטפת האחרונה וקבצים מרובי נמענים גזרו מפתח שגוי. התיקון הוא לאחסן עותק בבעלות עם List.Add(Pointer(System.Copy(Bytes)))
  • פתיחת מעטפות מפענחת את ה-DER במקום, ומעבר שחזור המפתח במקור חישב hash על אותם המערכים החיים. הקורא עכשיו מצלם עותקים טהורים של כל מעטפת לפני שכל פתיחה נוגעת בהן, וה-digest רץ על התמונות
  • DER בינארי שעובר דרך TStringList של Unicode מקבל בתים ב-$80 ומעלה מקודדים מחדש על ידי עמוד הקוד, ולכן HotPDF מאחסנת מעטפות כטקסט hex בפנים
  • מחרוזות מוצפנות ובינאריות חייבות להיכתב כמחרוזות hex. מחרוזת literal כפופה לנרמול סוף-שורה, שבו CR, LF ו-CRLF כולם הופכים ל-LF אחד (ISO 32000-1 §7.3.4.2), וזה משכתב את ה-ciphertext בשקט. HotPDF משגרת כל רשומת /Recipients כמחרוזת hex ופוטרת אותה מהצפנת מחרוזות, כי כל קורא צריך את המעטפות לפני שהוא מחזיק מפתח כלשהו
  • הבית הראשון של BIT STRING של DER סופר סיביות לא בשימוש וחייב להיות אפס עבור מפתחות מיושרי-בתים. להשאיר אותו לא מאותחל אחרי SetLength כתב מה שהיה על המחסנית, ומפתח קפדני של פתיחת מעטפות דחה את מפתח ה-originator, כך שקובץ יכול מדי פעם להיכשל בפתיחה עם בדיוק המפתח שעבורו נכתב
  • כשאותו מפתח עדיין לא מצליח לפענח, משווים שכבה שכבה: מפתח הקובץ, ואז קידומת ה-ciphertext (ה-IV), ואז מפתח האובייקט, ואז הטקסט הגלוי. הבאג חי מיד אחרי השכבה הראשונה שאינה מסכימה

איך פותחים PDF מוצפן תעודות עם מפתח פרטי?

כדי לפתוח PDF מוצפן תעודות, רושמים את חומרת המפתח הפרטי לפני הקריאה ל-LoadFromFile, כי HotPDF משחזרת את מפתח הקובץ במהלך המעבר המבני. מקצים מפתח RSA או EC שפוענח עם HPDFParsePFX אל PubSecKeyMaterial, מוסיפים מפתחות RSA נוספים עם AddPubSecKeyMaterial, ורושמים scalar-ים גולמיים של ECDH עם AddPubSecAgreementKeyMaterial(CurveOID, PrivateScalar, OwnPublicPoint), בשימוש בקבועים HPDFOIDX25519, HPDFOIDX448, HPDFOIDECP256, HPDFOIDECP384 או HPDFOIDECP521. העקומות של NIST דורשות את הנקודה הציבורית הלא דחוסה של הנמען עצמו; העקומות המונטגומריות מתעלמות ממנה

uses
  System.SysUtils, System.IOUtils, HPDFDoc, HPDFPFX, HPDFKeyAgreement;

procedure OpenAuditPack(const LegalScalar: TBytes);
var
  Reader: THotPDF;
begin
  Reader := THotPDF.Create(nil);
  try
    Reader.AutoLaunch := False;
    Reader.PubSecKeyMaterial :=
      HPDFParsePFX(TFile.ReadAllBytes('reviewer-a.pfx'), 'pfx-password');
    Reader.AddPubSecAgreementKeyMaterial(HPDFOIDX25519, LegalScalar, nil);
    // אופציונלי: בחירת המעטפת ישירות במקום לנסות את כולן
    Reader.PubSecRecipientQuery :=
      function(Context: Pointer; RecipientCount: Integer): Integer
      begin
        Result := -1;   // -1 = לנסות כל מעטפת לפי הסדר
      end;
    Reader.LoadFromFile('audit-pack.pdf', '');
    Writeln('Pages: ', Reader.GetLoadedPageCount);
  finally
    Reader.Free;
  end;
end;

בלי callback, HotPDF מנסה כל מעטפת מול כל מפתח רשום: את המפתח הראשי קודם, ואז כל מפתח RSA נוסף, ואז את חומרת ה-EC. ה-PubSecRecipientQuery מקבל את מספר המעטפות ומחזיר אינדקס מבוסס-0 או -1, ואינדקס מחוץ למערך מעלה חריגה במקום להיחתך. שימו לב ש-AddPubSecKeyMaterial מקבל רק חומרת RSA (הוא מתעקש על modulus ומעריך פרטי), ולכן מפתחות EC שייכים ל-PubSecKeyMaterial או ל-AddPubSecAgreementKeyMaterial. כשאף מפתח לא פותח אף מעטפת, שלב השחזור חוזר בלי מפתח קובץ במקום להעלות חריגה, ולכן מאמתים שהתוכן שציפיתם לו באמת פוענח בפועל במקום לסמוך על כך שקריאת הטעינה חזרה

דיאגרמת טעינת מפתח פרטי של HotPDF: PubSecKeyMaterial נושא את מפתח ה-RSA או ה-EC הראשי מ-HPDFParsePFX, AddPubSecKeyMaterial מוסיף רק מפתחות RSA, AddPubSecAgreementKeyMaterial רושם scalar-ים גולמיים של ECDH תחת ה-OID-ים של העקומות HPDFOIDX25519 ועד HPDFOIDP521, וב-LoadFromFile הספק מנסה את המפתח הראשי, ואז כל מפתח RSA נוסף, ואז את חומרת ה-EC מול כל מעטפת
כשאף מפתח לא פותח אף מעטפת שלב השחזור חוזר בלי מפתח קובץ במקום להעלות חריגה, ולכן מאמתים שהתוכן באמת פוענח או נועצים את המעטפת דרך PubSecRecipientQuery

מה HotPDF לא מבטיחה

HotPDF מבטיחה שהכותב והקורא שלה מסכימים בית אחר בית, והיא בונה מעטפות שעוקבות אחרי מבני ה-CMS המצוטטים למעלה. היא לא מבטיחה שכל מציג PDF פותח כל שילוב. התמיכה בהעברת מפתח RSA-OAEP ובנמעני X25519 או X448 משתנה בין קוראים וגרסאות, ולא פרסמנו תוצאות תאימות עבור השילובים האלה. אם מסמך חייב להיפתח במציג ספציפי, מצפינים קובץ בדיקה עבור תעודת בדיקה מאותו סוג מפתח ופותחים אותו שם לפני שמתחייבים ל-scheme. ההרשאות שנישאות במעטפת נשארות מדיניות שתוכנה תואמת מכבדת, בדיוק כפי שהן תחת הצפנת סיסמה. איכות ה-seed היא גם האחריות שלכם: AESGenerateRandomBytes קיים בשביל העבודה הזאת, ו-HotPDF מוחקת את עותק ה-seed שלה ברגע שמפתח הקובץ נגזר. אם אתם צריכים גם שמחרוזת, זרם או קובץ מצורף ישתמשו ב-crypt filter אחר, המדריך למדיניות crypt filter עבור StmF, StrF ו-EFF מציג אילו שמות מסננים המטפל של המפתח הציבורי מקבל

הצפנת תעודות, מעטפות נמענים של RSA-OAEP ו-ECDH, וטעינת מפתחות פרטיים מגיעים כולם ברכיב HotPDF ל-Delphi, לצד הצפנת סיסמה, חתימות דיגיטליות ושאר ארגז הכלים של ISO 32000 עבור Delphi ו-C++Builder