מאמר טכני

הצפנת AES-256 מהירה ל-PDF עבור מסמכים ענקיים

הצפנת PDF בנפח של 2 GB נשמעת כמו בעיית הזרמה (streaming): פתח את הקובץ, דחוף שני ג'יגה-בייט דרך AES-256, כתוב את התוצאה. המודל המנטלי הזה שגוי בדרך שמכריעה את כל תקציב הביצועים. ISO 32000-1 §7.6 קובע את הגרנולריות של הצפנת PDF ברמת האובייקט הבודד — כל זרם וכל מחרוזת מוצפנים בנפרד, כל אחד עם וקטור אתחול (initialization vector - IV) משלו וריפוד (padding) משלו. ארכיון סרוק של 2 GB עם 500,000 אובייקטים הוא 500,000 פעולות CBC קטנות, ולא מעבר ארוך אחד, ובקנה מידה כזה העלות הקבועה סביב כל פעולה חשובה יותר מהאריתמטיקה של AES בתוכה

מאמר זה עוסק בעלות הקבועה הזו: לאן הולך הזמן כאשר קוד Delphi מחיל AES-256 על מסמכים גדולים מאוד, וכיצד להשיג אותו בחזרה. עבור צד ההתקנה — סיסמאות, דגלי הרשאות, וקריאת התאימות של revision 5 לעומת 6 — ראו את מאמר הלוויה בנושא הגדרת הצפנת AES-256 ב-HotPDF; שום דבר מזה לא חוזר כאן

חצי מיליון פעולות CBC, לא מעבר אחד

השלד של הקובץ נשאר בטקסט רגיל (plaintext). טבלאות הפניות צולבות, מספרי אובייקטים, מפתחות מילון, עץ העמודים: שום דבר מזה אינו מוצפן, וכך קורא יכול לאתר אובייקטים לפני שאימת סיסמה. מה שהתקן מצפין הוא תוכן — נתוני זרם כגון תיאורי עמודים, תמונות, גופנים וקבצים מצורפים, בתוספת מחרוזות כגון ערכי מטא-נתונים וטקסט של הערות (annotations). תחת מסנן ההצפנה AES-256 כל אחד מעובד בפני עצמו: IV אקראי וטרי של 16 בתים, CBC על הבתים, ריפוד בלוק לגבול של 16 בתים, וה-IV נכתב בגלוי לפני הטקסט המוצפן (ciphertext)

שתי השלכות נובעות מכך. ראשית, טקסט מוצפן תמיד ארוך יותר מטקסט רגיל: ה-IV מוסיף 16 בתים והריפוד מוסיף עוד 1 עד 16 בתים, כך שמחרוזת של 100 בתים תופסת 128 בתים על הדיסק וזרם ריק עדיין מייצר 32. קוד שמתאים את גודל חוצץ הפלט לאורך הקלט, או שכותב חזרה רק כמה בתים שהוא קרא, מייצר קבצים שנכשלים בפענוח בבלוק האחרון של כל אובייקט. שנית, העלות עוקבת אחרי ספירת האובייקטים, לא רק ספירת הבתים. ארכיון סרוק מרכז את הבתים שלו במספר זרמי תמונות גדולים, אך נושא מאות אלפי זרמים קצרים ומחרוזות קטנות שבהם התקורה לכל-פעולה, לא ה-AES, היא החשבון המכריע

החסד היחיד בתכנון ה-AES-256 הוא הטיפול במפתחות. מנהלי אבטחה (security handlers) עד revision 4 גזרו מפתח שונה לכל אובייקט על ידי גיבוב (hashing) מפתח הקובץ יחד עם מספרי האובייקט והדור, מה שאילץ לוח זמנים חדש של מפתח בכל פעם. סכמות ה-/V 5 זנחו את הגזירה פר-אובייקט: מפתח קובץ אקראי אחד של 256 ביט מצפין כל אובייקט במסמך. עובדה זו מאשרת כל אופטימיזציה בהמשך — ניתן לבנות את המצב הקריפטוגרפי היקר פעם אחת לקובץ, לא פעם אחת לכל אובייקט

מילון ה-/Encrypt ב-R6: פתיחה אחת איטית, אובייקטים זולים

מסמך revision 6 מצהיר על הסכמה שלו במילון ה-/Encrypt של ה-trailer, והערכים שחשובים נכנסים למספר שורות:

/Filter /Standard
/V 5  /R 6  /Length 256
/CF << /StdCF << /CFM /AESV3  /Length 32  /AuthEvent /DocOpen >> >>
/StmF /StdCF    /StrF /StdCF
/O ...48 bytes...   /U ...48 bytes...
/OE ...32 bytes...  /UE ...32 bytes...
/Perms ...16 bytes...  /P -3904  /EncryptMetadata true

/V 5 בוחר בארכיטקטורת מפתח של 256 ביט ו-/R 6 בוחר בלחיצת היד (handshake) המוקשחת של ISO 32000-2. ה-/CF מגדיר את מסנן ההצפנה בשמו — /AESV3 פירושו AES-256 במצב CBC עם ה-IV שצורף להתחלה — וה-/StmF וה-/StrF מקצים את המסנן הזה לזרמים ולמחרוזות בהתאמה. /O, /U, /OE ו-/UE מחזיקים את אימות הסיסמה ואת החומר העוטף-מפתחות (key-wrapping), וה-/Perms נושא עותק מוצפן-AES של סיביות ההרשאה כך שעורך עוין לא יוכל להפוך בשקט את /P

מבנה העלויות מסתתר ב-/OE ו-/UE. פרישת (unwrapping) מפתח הקובץ מהם מפעילה את אלגוריתם 2.B, פונקציית גזירת מפתח איטרטיבית (iterated key-derivation function) המשרשרת סבבי SHA-256, SHA-384 ו-SHA-512 — לפחות 64 מהם, עם כלל עצירה התלוי בנתונים — שנבנתה במכוון להיות איטית כך שניחוש סיסמאות יישאר יקר. מחיר זה משולם פעם אחת כאשר הכותב מייצר את הקובץ ופעם אחת כאשר קורא פותח אותו, כל אחד נמדד במילישניות בודדות. בקובץ של חצי מיליון אובייקטים ה-KDF הוא רעש, ואם השמירה איטית, אלגוריתם 2.B אינו החשוד; הלולאה פר-אובייקט היא האשמה

שימוש חוזר ב-key handle, שימוש חוזר ב-scratch buffer

המימוש הנאיבי (naive implementation) הוא פונקציית עזר מסודרת: עוזר (helper) EncryptAes256Cbc הפותח את ספק ה-CNG של Windows, בוחר ב-CBC, מייצר את אובייקט המפתח, מצפין חוצץ אחד, וקורע את הכל. נכון, ניתן לבדיקת יחידה (unit-testable), והרסני בתוך לולאה של 500,000 איטרציות. התיעוד של Microsoft מסמן את BCryptOpenAlgorithmProvider כיקר וממליץ לשמור את הידית במטמון (caching the handle), ו-BCryptGenerateSymmetricKey מריץ את כל לוח הזמנים של מפתח AES ומקצה מצב ספק (provider state) — בזבוז טהור כאשר המפתח לא משתנה לעולם לאורך המסמך

ספריית העזר (RTL) של Delphi לא כוללת יחידת ייבוא ל-bcrypt, אז הכריזו על נקודות הכניסה ישירות. המחלקה להלן בונה את כל המצב הקריפטוגרפי פעם אחת ואז מצפינה כל מספר של אובייקטים ללא הקצאה במצב יציב (steady-state allocation):

uses
  Winapi.Windows, System.SysUtils, System.Classes;

const
  BCRYPT_AES_ALGORITHM  = 'AES';
  BCRYPT_CHAINING_MODE  = 'ChainingMode';
  BCRYPT_CHAIN_MODE_CBC = 'ChainingModeCBC';
  BCRYPT_OBJECT_LENGTH  = 'ObjectLength';
  BCRYPT_BLOCK_PADDING            = $00000001;
  BCRYPT_USE_SYSTEM_PREFERRED_RNG = $00000002;

type
  NTSTATUS = Integer;
  BCRYPT_HANDLE = Pointer;

function BCryptOpenAlgorithmProvider(out hAlg: BCRYPT_HANDLE; AlgId,
  Impl: PWideChar; Flags: ULONG): NTSTATUS; stdcall; external 'bcrypt.dll';
function BCryptCloseAlgorithmProvider(hAlg: BCRYPT_HANDLE;
  Flags: ULONG): NTSTATUS; stdcall; external 'bcrypt.dll';
function BCryptSetProperty(hObj: BCRYPT_HANDLE; Prop: PWideChar; Input: PByte;
  cbInput, Flags: ULONG): NTSTATUS; stdcall; external 'bcrypt.dll';
function BCryptGetProperty(hObj: BCRYPT_HANDLE; Prop: PWideChar; Output: PByte;
  cbOutput: ULONG; out cbResult: ULONG; Flags: ULONG): NTSTATUS; stdcall;
  external 'bcrypt.dll';
function BCryptGenerateSymmetricKey(hAlg: BCRYPT_HANDLE;
  out hKey: BCRYPT_HANDLE; KeyObj: PByte; cbKeyObj: ULONG; Secret: PByte;
  cbSecret: ULONG; Flags: ULONG): NTSTATUS; stdcall; external 'bcrypt.dll';
function BCryptDestroyKey(hKey: BCRYPT_HANDLE): NTSTATUS; stdcall;
  external 'bcrypt.dll';
function BCryptEncrypt(hKey: BCRYPT_HANDLE; Input: PByte; cbInput: ULONG;
  Padding: Pointer; IV: PByte; cbIV: ULONG; Output: PByte; cbOutput: ULONG;
  out cbResult: ULONG; Flags: ULONG): NTSTATUS; stdcall; external 'bcrypt.dll';
function BCryptGenRandom(hAlg: BCRYPT_HANDLE; Buffer: PByte;
  cbBuffer, Flags: ULONG): NTSTATUS; stdcall; external 'bcrypt.dll';

procedure CngCheck(Status: NTSTATUS; const Api: string);
begin
  if Status <> 0 then
    raise Exception.CreateFmt('%s failed, NTSTATUS 0x%.8x',
      [Api, Cardinal(Status)]);
end;

type
  TPdfObjectEncryptor = class
  private
    FAlg: BCRYPT_HANDLE;
    FKey: BCRYPT_HANDLE;
    FKeyObject: TBytes;  // CNG key-object workspace, allocated once
    FScratch: TBytes;    // ciphertext scratch, grows and then stays
  public
    constructor Create(const FileKey: TBytes);
    destructor Destroy; override;
    procedure EncryptObject(const Plain: TBytes; Dest: TStream);
  end;

constructor TPdfObjectEncryptor.Create(const FileKey: TBytes);
var
  Mode: string;
  ObjLen, Got: ULONG;
begin
  inherited Create;
  if Length(FileKey) <> 32 then
    raise Exception.Create('AES-256 file key must be 32 bytes');
  CngCheck(BCryptOpenAlgorithmProvider(FAlg, BCRYPT_AES_ALGORITHM, nil, 0),
    'BCryptOpenAlgorithmProvider');
  Mode := BCRYPT_CHAIN_MODE_CBC;
  CngCheck(BCryptSetProperty(FAlg, BCRYPT_CHAINING_MODE,
    PByte(PWideChar(Mode)), (Length(Mode) + 1) * SizeOf(WideChar), 0),
    'BCryptSetProperty');
  CngCheck(BCryptGetProperty(FAlg, BCRYPT_OBJECT_LENGTH, PByte(@ObjLen),
    SizeOf(ObjLen), Got, 0), 'BCryptGetProperty');
  SetLength(FKeyObject, ObjLen);
  // The AES key schedule is built once here and reused for every object
  CngCheck(BCryptGenerateSymmetricKey(FAlg, FKey, PByte(FKeyObject), ObjLen,
    PByte(FileKey), 32, 0), 'BCryptGenerateSymmetricKey');
end;

destructor TPdfObjectEncryptor.Destroy;
begin
  if FKey <> nil then
    BCryptDestroyKey(FKey);
  if FAlg <> nil then
    BCryptCloseAlgorithmProvider(FAlg, 0);
  inherited;
end;

procedure TPdfObjectEncryptor.EncryptObject(const Plain: TBytes; Dest: TStream);
var
  IV, IVWork: array[0..15] of Byte;
  Need, Written: ULONG;
  Src: PByte;
begin
  // Fresh random IV per object; it travels in the clear ahead of the data
  CngCheck(BCryptGenRandom(nil, @IV[0], 16, BCRYPT_USE_SYSTEM_PREFERRED_RNG),
    'BCryptGenRandom');
  Src := PByte(Plain);  // nil for an empty input is valid: padding-only block

  // Size query: CBC padding always adds 1..16 bytes, so Need > Length(Plain)
  IVWork := IV;  // BCryptEncrypt advances the IV buffer while it chains
  CngCheck(BCryptEncrypt(FKey, Src, Length(Plain), nil, @IVWork[0], 16,
    nil, 0, Need, BCRYPT_BLOCK_PADDING), 'BCryptEncrypt(size)');

  if ULONG(Length(FScratch)) < Need then
    SetLength(FScratch, Need);  // grows a handful of times, then stays put

  IVWork := IV;
  CngCheck(BCryptEncrypt(FKey, Src, Length(Plain), nil, @IVWork[0], 16,
    PByte(FScratch), Need, Written, BCRYPT_BLOCK_PADDING), 'BCryptEncrypt');

  // AESV3 layout: the 16-byte IV, then the padded ciphertext
  Dest.WriteBuffer(IV[0], 16);
  Dest.WriteBuffer(FScratch[0], Written);
end;

שלושה פרטים הם נושאי העומס. שאלת הגודל (size query) — קריאת ה-BCryptEncrypt הראשונה, עם חוצץ פלט nil — מחזירה את אורך הטקסט המוצפן המרופד, לעולם לא שווה לאורך הקלט; הריפוד הוא דטרמיניסטי, כך שאתם יכולים לחשב את ((Len div 16) + 1) * 16 בעצמכם ולחצות את ספירת הקריאות, אבל השאילתה היא החוזה המתועד. שנית, BCryptEncrypt מקדם את חוצץ ה-IV במקום בזמן שהוא משרשר, כך שעותק עבודה נכנס לכל קריאה וה-IV הטהור נחת בפלט. שלישית, FScratch רק גדל, עד לאובייקט הגדול ביותר בקובץ, ולאחר מכן הלולאה לא מקצה שום דבר

כמה שווה שימוש חוזר בידית, במדידה

הקובץ שאילץ את התרגיל הזה היה ארכיון הלוואות סרוק בנפח 1.8 GB: 412,000 אובייקטים מוצפנים הנושאים 1,710 MB של מטען (payload) ברגע שמחסירים את מבנה הטקסט הרגיל. אותה מכונה, אותו קובץ, אחסון NVMe, חוט (thread) אחד:

  • הקמה לכל קריאה (ספק נפתח ומפתח מיוצר בתוך העוזר): שלב ההצפנה 71.3 שניות — 1,710 MB ÷ 71.3 שניות ≈ 24 MB/s
  • מצב מורם (hoisted) (המחלקה למעלה): 9.6 שניות — 1,710 MB ÷ 9.6 שניות ≈ 178 MB/s

ההבדל הוא 61.7 שניות על פני 412,000 קריאות, או בערך 150 µs לקריאה שהושקעו בפתיחת ספק, קביעת מצב שרשור ובנייה מחדש של לוח זמנים של מפתח עבור מפתח שלעולם לא השתנה. שום דבר מזה לא היה קריפטוגרפיה. עם AES-NI, הצפנת CBC של חוצצים גדולים פועלת קרוב ל-1.4 GB/s על ליבה אחת, כך שהאריתמטיקה של AES עצמה אחראית לכ-1.2 שניות מתוך ה-9.6; רוב השאר הוא שני המעברים של BCryptEncrypt במצב משתמש (user-mode) לכל אובייקט פלוס ייצור IV לכל אובייקט. איסוף של ה-IVs לקבוצות (Batching) — קריאת BCryptGenRandom אחת שממלאת 4,096 מהם — קיצצה את ההרצה ל-8.9 שניות. מעבר לזה אתם ברצפה פר-אובייקט של ה-API, והמנוף שנותר הוא מקביליות: אובייקטים /V 5 הם בלתי תלויים תחת מפתח הקובץ המשותף, כך שארבעה שרשורי עובדים עם אובייקט מפתח אחד לכל אחד לקחו את השלב ל-3.1 שניות לפני שכותב הפלט הפך לנקודת הסריאליזציה

שכתוב מלא לעומת שמירה אינקרמנטלית

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

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

מדידת תפוקה מבלי לעבוד על עצמכם

טענות על תפוקת הצפנה נוטות להיות שגויות במונה, במכנה, או בשניהם. המונה צריך להיות בתי המטען (payload bytes): סכום אורכי הזרם והמחרוזות שנדחפו בפועל דרך AES, לאחר דחיסה, שהכותב יכול לסכם תוך כדי תנועה. גודל הקובץ מפריז בזה — הארכיון לעיל הוא 1.8 GB בדיסק, אך רק 1,710 MB ממנו נוגע אי פעם בצופן. המכנה צריך להיות שלב ההצפנה בלבד, מוקף ב-TStopwatch מ-System.Diagnostics, עם הניתוח (parsing), הדחיסה (deflate) וה-I/O של הדיסק מחוץ להקפה. קפלו אותם פנימה וקוד ההצפנה הזהה יימדד לאט פי כמה על קובץ שרק נדחס פחות טוב. הנתונים לעיל ברי השוואה דווקא משום ששני הצדדים של החלוקה הם הצפנה-בלבד

אף אחד מהקודים הללו לא חייב להיות בבעלותכם. HotPDF עוטף את אותה הנדסה מאחורי מאפייני רכיב — ActivateProtection, CryptKeyLength, UseAES256R6 — בגובה הנכון עבור יישומי VCL אינטראקטיביים, עם מלכודות סדר ההשמה (assignment-order pitfalls) המכוסות במאמר ה-AES-256 של HotPDF. עבור צינורות נתונים ללא השגחה, PDFlibPas מחיל AES-256 revision 6 על קבצים קיימים בקריאת EncryptFile בודדת בעוצמה 4 (Strength 4) ומוודא לאחר מכן מה נחת על הדיסק, זרימת עבודה שעוברים עליה במאמר ביקורת ההצפנה של PDFlibPas

נתיבי ההצפנה המתוארים כאן מסופקים ב-HotPDF Component עבור Delphi ו-C++Builder וב-ספריית PDFlibPas; שני דפי המוצר נושאים את הרפרנס המלא של ההצפנה