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 מקודמיו הוא שזרם ה-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
// Works for plain .xlsx, Standard-encrypted and
// Agile-encrypted files alike
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): היא עולה לקורא לגיטימי כמה מילי-שניות פעם אחת, ועולה לתוקף מבוסס מילון את אותן מילי-שניות לכל ניחוש בודד
// [MS-OFFCRYPTO] iterated password hash:
// H(0) = SHA-512(salt + UTF-16LE(password))
// H(n) = SHA-512(LE32(n - 1) + H(n - 1)), repeated spinCount times
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 הרגיל שלו
גודל הטקסט הפשוט המוצהר מבצע את עבודת הגמר. פלט 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
// Raised for both a wrong password and an unsupported
// scheme; E.Message states which, so log it verbatim and
// only offer a password retry for the wrong-password case
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