מאמר טכני

קריאת קובצי Excel המוצפנים ב-Agile Encryption בדלפי באמצעות HotXLS

HotXLS קורא קובצי Excel המוצפנים ב-Agile Encryption (הגנת הסיסמה ש-Excel 2010 וכל הגרסאות הבאות אחריו מחילים כברירת מחדל) באמצעות קריאה אחת: TXLSXWorkbook.OpenEncrypted. הרכיב מפענח את תיאור ההצפנה ב-XML, גוזר מפתחות מהסיסמה בעזרת שרשרת גיבוב SHA-512 מבוססת סבבים (spin-count), מאמת את הסיסמה מול המאמת המוצפן, ולאחר מכן מפענח את החבילה במקצעי AES-CBC של 4096 בתים. אין צורך בהתקנת Excel, ב-COM או ב-DLL חיצוני של קריפטוגרפיה

מאמר זה מכסה ספציפית את צד הקריאה של Agile Encryption. שתי בעיות שכנות זוכות למאמרים משלהן: תאימות הדדית עם סכמות ה-RC4 וה-XOR הישנות בתוך קובצי .xls היסטוריים מסוג BIFF מכוסה במאמר על תאימות ECB ו-RC4, והפקת חוברות עבודה מוגנות בסיסמה עם ECMA-376 Standard Encryption מכוסה במאמר על פלט XLSX מוגן ב-AES. כאן הקובץ כבר קיים, מישהו אחר הצפין אותו, והמשימה שלכם היא לפתוח אותו

התרחיש שמאלץ את הנושא מוכר לכל מי שמפעיל צינור עיבוד מסמכים. שירות ייבוא בצד השרת מקבל העלאות של חוברות עבודה; אין Excel במכונה ולעולם לא יהיה; ובוקר אחד לקוח מעלה קובץ .xlsx רגיל לחלוטין שקורא ה-ZIP דוחה מכיוון שהוא כלל אינו ZIP. הלקוח שמר אותו עם סיסמה. מאותו רגע, הטוען שלכם חייב להבין את [MS-OFFCRYPTO] או שהוא יחזיר את הקובץ ללקוח שמן נקודת מבטו לא עשה שום דבר חריג

מהי הצפנת Agile בקובץ Excel?

הצפנת Agile היא סכמת הגנת הסיסמה המוגדרת בתקן [MS-OFFCRYPTO] סעיפים §2.3.4.10 עד §2.3.4.15, וזה מה ש-Excel 2010 וגרסאות מאוחרות יותר כותבים בכל פעם שחוברת עבודה נשמרת עם סיסמה. הקובץ המוצפן אינו עוד חבילת ZIP. זהו מכולת OLE Compound File Binary (CFB) המכילה שני זרמים: EncryptionInfo, המתאר כיצד בוצעה ההצפנה, ו-EncryptedPackage, שהוא קובץ ה-ZIP האמיתי של ה-.xlsx המוצפן כ-blob אטום. חתימת ה-CFB (הערכים D0 CF 11 E0 A1 B1 1A E1) היא אותו קסם שקובצי .xls מסוג BIFF הישנים נושאים, וזו הסיבה שלא ניתן לסווג קובץ שונה או מוצפן לפי הסיומת שלו בלבד

אנטומיה של חוברת מוצפנת Agile ש-HotXLS פותח ב-Delphi: מיכל CFB המחזיק זרם מתאר EncryptionInfo וזרם EncryptedPackage של מקטעי AES-CBC
HotXLS קורא חוברת מוצפנת Agile כמכולת CFB עם stream EncryptionInfo המתאר-את-עצמו ואת ה-.xlsx מוצפן כ-stream EncryptedPackage אטום

מה שמבדיל את Agile מקודמיו הוא שזרם ה-EncryptionInfo מתאר את עצמו. לאחר תחילית גרסה בת 8 בתים, שבה הגרסה הראשית והמשנית הן 4, הזרם הוא תיאור XML בקידוד UTF-8. אלמנט keyData מצהיר על הצופן (AES), מצב השרשור (ChainingModeCBC), הגיבוב (SHA512), אורך המפתח בסיביות, גודל הבלוק ומלח (salt) בקידוד Base64. אלמנט סיסמה מסוג keyEncryptor נושא מלח משלו, את ה-spinCount ושלושה מטעני Base64: הערכים encryptedVerifierHashInput, encryptedVerifierHashValue ו-encryptedKeyValue. Excel כותב AES-256 עם ספירת סבבים של 100,000, אך התיאור מורשה להצהיר על AES-128 או AES-192, ו-HotXLS מכבד את מה ש-keyBits מציין במקום להניח מראש 256

נקודת כניסה אחת לחוברות עבודה בטקסט פשוט, Standard ו-Agile

הפונקציה TXLSXWorkbook.OpenEncrypted מטפלת בכל שלושת המצבים שבהם קורא יכול להיתקל: ZIP רגיל, הצפנת Standard והצפנת Agile, כך שמטפלי העלאות אינם צריכים לסווג קבצים לפני טעינתם. המתודה מרחרחת תחילה את הקובץ: אם אין חתימת CFB, היא מעבירה לנתיב ה-Open הרגיל והסיסמה פשוט זוכה להתעלמות. אם הקובץ הוא מכולת CFB, היא מנסה תחילה הצפנת Standard של ECMA-376, וכאשר חתימת הגרסה של EncryptionInfo היא Agile 4.4, היא מנתבת לצינור העיבוד של Agile. ערך ההחזרה הוא 1 בהצלחה, אותו חוזה כמו Open

var
  Wb: TXLSXWorkbook;
begin
  Wb := TXLSXWorkbook.Create;
  try
    // פועל עבור .xlsx רגיל, קבצים מוצפנים ב-Standard וגם
    // קבצים מוצפנים ב-Agile כאחד
    if Wb.OpenEncrypted('upload.xlsx', 'customer-password') = 1 then
      Writeln(VarToWideStr(Wb.Sheets[1].Cells[1, 1].Value));
  finally
    Wb.Free;
  end;
end;

הנסיגה (fallback) עבור קלט לא מוצפן חשובה מכפי שהיא נראית. מייבא באצווה שתמיד קורא ל-OpenEncrypted אינו זקוק להסתעפות בנקודת הקריאה: קבצים שמעולם לא הוגנו נטענים בדיוק כמו קודם, וקבצים שמגיעים מוצפנים מופענחים במקום ומוזנים לקורא ה-ZIP הרגיל כזרם בזיכרון. יש רק נתיב קוד אחד לבדוק, ולא שלושה

כיצד סיסמה הופכת למפתח AES?

הצפנת Agile לעולם אינה משתמשת בסיסמה ישירות. HotXLS מחשב תחילה גיבוב חוזר: הסיכום הראשוני הוא SHA-512 על פני מלח הסיסמה משורשר עם בתי ה-UTF-16LE של הסיסמה, ולאחר מכן הסיכום מגובב מחדש spinCount פעמים, כאשר כל סבב מקדים את מונה האיטרציה של 32 סיביות ב-little-endian לגיבוב הקודם. עם ספירת סבבים של 100,000 כברירת מחדל ב-Excel, פירוש הדבר הוא מאה אלף קריאות SHA-512 רציפות לכל ניסיון סיסמה, וזהו כל הרעיון. ספירת הסבבים היא ויסות מול התקפת כוח גס (brute-force throttle): היא עולה לקורא לגיטימי כמה מילי-שניות פעם אחת, ועולה לתוקף מבוסס מילון את אותן מילי-שניות לכל ניחוש בודד

כיצד HotXLS גוזר מפתחות הצפנה Agile ב-Delphi: הסיסמה נטחנת דרך 100,000 סבבי SHA-512, ואז שלושה מפתחות בלוק קבועים של 8 בייטים מניבים את מפתח ה-verifier ומפתחות החבילה
הסיסמה לעולם אינה מפענחת דבר ישירות: שרשרת ספירת-סיבובים של SHA-512 מזינה שלושה האשים של מפתח-בלוק שמייצרים את מפתחות ה-verifier וחולצים את מפתח החבילה האקראי
// [MS-OFFCRYPTO] איטרציית hash של הסיסמה:
//   H(0) = SHA-512(salt + UTF-16LE(password))
//   H(n) = SHA-512(LE32(n - 1) + H(n - 1)), חוזר spinCount פעמים
function AgilePasswordHash(const Password: WideString;
  const Salt: TBytes; SpinCount: Integer): TBytes;
var
  buf: TBytes;
  i: Integer;
begin
  Result := XlsSHA512(Concat(Salt, Utf16LEBytes(Password)));
  SetLength(buf, 4 + 64);
  for i := 0 to SpinCount - 1 do
  begin
    PutLE32(buf, 0, i);            // iteration counter, little-endian
    Move(Result[0], buf[4], 64);   // previous digest
    Result := XlsSHA512(buf);
  end;
end;

הגיבוב המותך עדיין אינו מפתח. שלושה מפתחות נפרדים נגזרים ממנו על ידי גיבובו פעם נוספת עם מפתח בלוק קבוע של 8 בתים המשורשר אליו, קבוע אחד לכל מטרה: הערכים FE A7 D2 76 3B 4B 9E 79 לפענוח קלט המאמת, D7 AA 0F 6D 30 61 34 4E לגיבוב המאמת, ו-14 6E 0B E7 AB AC D0 D6 לחילוץ מפתח החבילה בפועל. כל תוצאת SHA-512 נקטמת לאורך המפתח המוצהר, ועל פי [MS-OFFCRYPTO], מרופדת בבתי 0x36 במקרה התיאורטי שבו הגיבוב קצר מהמפתח. אותו כלל ריפוד של 0x36 חל כאשר מלח הסיסמה מורחב לגודל הבלוק לצורך שימוש כווקטור האתחול (IV) של ה-CBC

אימות סיסמה ומלכודת קטימת ה-saltSize

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

There is a specification detail here that is easy to get wrong. [MS-OFFCRYPTO] §2.3.4.13 defines the verifier as saltSize bytes of random data, where saltSize is the length of the key encryptor's salt, not the cipher block size. Because AES-CBC ciphertext is block-aligned, the decrypted verifier input comes back padded to a multiple of 16 bytes, and it must be truncated back to saltSize before hashing. Excel always writes saltSize equal to blockSize, both 16, so an implementation that skips the truncation passes every test against real Excel output and then fails on the first file from a producer that chose a different salt length. HotXLS truncates to the salt length because that is what the specification actually says, and the two values agreeing in practice is a coincidence, not a contract

כיצד מבוצע הפענוח של ה-EncryptedPackage?

זרם ה-EncryptedPackage מתחיל בגודל טקסט פשוט בן 8 בתים ב-little-endian, ולאחריו הטקסט המוצפן במקטעים של 4096 בתים, ו-HotXLS מפענח אותו מקטע אחר מקטע עם IV חדש לכל מקטע. מפתח החבילה עצמו אינו נגזר מהסיסמה: זהו מפתח ביניים אקראי שכותב הקובץ הצפין לתוך encryptedKeyValue, ו-HotXLS מחלץ אותו עם המפתח הנגזר השלישי, כשהוא קוטם לאורך המפתח המוצהר על ידי keyData. ה-IV של כל מקטע הוא SHA-512 על מלח ה-keyData משורשר עם אינדקס המקצע בן 32 סיביות ב-little-endian, כשהוא קטום לגודל הבלוק. מבנה זה אומר שכל מקטע של 4096 בתים יכול להיות מפוענח באופן עצמאי, וזה מה שתוכנן אקראי בעיקרון, למרות ש-HotXLS מפענח את כל החבילה לזיכרון ומעביר את בתי ה-ZIP המתקבלים לטוען ה-XLSX הרגיל שלו

HotXLS מפענח את זרם ה-EncryptedPackage ב-Delphi מקטע אחר מקטע, וגוזר את ה-IV של כל מקטע AES-CBC בן 4096 בייטים מה-salt של keyData ומאינדקס המקטע
כל segment של AES-CBC בן 4096 בתים מפוענח באופן בלתי תלוי תחת IV מלח-ואינדקס משלו, והתוצאה נקטמת אל הגודל המוצהר של הטקסט הגלוי

גודל הטקסט הפשוט המוצהר מבצע את עבודת הגמר. פלט AES-CBC מיושר לפי בלוקים, ולכן המקצע האחרון נושא עד 15 בתים של ריפוד שאינם חלק מהמסמך; הבאפר המפוענח נקטם לקידומת הגודל, והתוצאה היא בדיוק ה-ZIP של ה-.xlsx ש-Excel הצפין. HotXLS מאמת את הקידומת מול אורך הזרם בפועל לפני הפענוח, כך שהעלאה קטומה או שדה גודל שונה נכשלים בצורה נקייה במקום לגלוש

דיווח על שגיאות וגבולות כנים

מצבי הכשל מופרדים במכוון. סיסמה שגויה מעלה חריגה (exception) עם הודעת סיסמה שגויה מפורשת, הנובעת מאי-התאמה של המאמת, כך שממשק משתמש יכול לבקש מהמשתמש לנסות שוב. מכולת CFB שהתיאור שלה מצהיר על אלגוריתמים מחוץ לסט הנתמך (כל דבר מלבד AES עם שרשור CBC וגיבוב SHA-512 בתיאור Agile), או מכולת שאינה Standard ואינה Agile, מעלה חריגה שונה המזהה את הסכמה כלא נתמכת. אסור לערבב בין השניים לעולם: ניסיון חוזר של סיסמה מול סכמה לא נתמכת מבזבז את זמנו של המשתמש, ודיווח על סיסמה שגויה כשגיאת פורמט שולח את צוות התמיכה שלכם לנתיב הלא נכון

function LoadUploadedWorkbook(const FileName: WideString;
  const Password: WideString; Wb: TXLSXWorkbook): Boolean;
begin
  Result := False;
  try
    Result := Wb.OpenEncrypted(FileName, Password) = 1;
  except
    on E: EXlsxEncryptionNotImplemented do
      // נזרקת גם עבור סיסמה שגויה וגם עבור
      // scheme שאינו נתמך; E.Message מציין איזו, לכן יש לתעד אותו verbatim וגם
      // להציע ניסיון חוזר של סיסמה רק במקרה של סיסמה שגויה
      RejectUpload(FileName, E.Message);
  end;
end;

ראוי לציין את הגבולות בגלוי. HotXLS קורא תיאורי Agile המצהירים על AES במצב CBC עם SHA-512, מה שמכסה את מה ש-Excel 2010 עד Excel 365 כותבים בפועל, בכל שלושת גדלי המפתחות. תיאורים המצהירים על צפנים או אלגוריתמי גיבוב אחרים נדחים במקום לנחש אותם, ומצפיני מפתחות מבוססי תעודה אינם נבדקים, אלא רק מצפין מפתח הסיסמה. בצד הכתיבה, HotXLS מייצר כיום הצפנת Standard במקום Agile, הבחנה שחשובה אם כלי המשך בודקים את הסכמה; הפרטים נמצאים במאמר על כתיבת פלט XLSX מוגן ב-AES

העלאות מוגנות בסיסמה מפסיקות להיות מקרה מיוחד ברגע שהטוען מתייחס להצפנה כחלק מפורמט הקובץ ולא כחריג לו. נקודת הכניסה OpenEncrypted, גזירת ה-SHA-512 מבוססת סבבים, וצינור ה-AES-CBC המקוטע המתוארים כאן מסופקים כחלק מ-רכיב HotXLS Delphi Excel, לצד יתר מנוע הקריאה והכתיבה הטבעי של XLS ו-XLSX עבור דלפי ו-C++Builder