Технічна стаття

Читання Excel із шифруванням Agile у Delphi з HotXLS

HotXLS читає файли Excel із шифруванням Agile, тобто з тим захистом паролем, який Excel 2010 і кожна пізніша версія застосовують типово, через єдиний виклик: TXLSXWorkbook.OpenEncrypted. Компонент розбирає XML-дескриптор шифрування, виводить ключі з пароля ланцюжком хешів SHA-512 із лічильником обертів, звіряє пароль із зашифрованим верифікатором, а потім дешифрує пакет сегментами AES-CBC по 4096 байтів. Жодного встановленого Excel, жодного COM, жодної зовнішньої криптографічної DLL тут не задіяно

Ця стаття покриває саме бік читання шифрування Agile. Дві сусідні задачі мають власні статті: сумісність зі спадковими схемами RC4 та XOR усередині старих файлів BIFF .xls розглянуто в статті про сумісність з ECB та RC4, а створення захищених паролем робочих книг зі стандартним шифруванням ECMA-376 - у статті про вивід 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, зашифрованим як непрозорий блок. Сигнатура CFB (D0 CF 11 E0 A1 B1 1A E1) є тією самою магією, яку несуть спадкові файли BIFF .xls, і саме тому перейменований чи зашифрований файл не можна класифікувати за самим лише розширенням

Анатомія робочої книги із шифруванням Agile, яку HotXLS відкриває в Delphi: контейнер CFB із потоком-дескриптором EncryptionInfo та потоком EncryptedPackage із сегментів AES-CBC
HotXLS читає робочу книгу із шифруванням Agile як контейнер CFB із самоописовим потоком EncryptionInfo та ZIP-файлом .xlsx, зашифрованим як непрозорий потік EncryptedPackage

Agile відрізняє від попередників те, що EncryptionInfo є самоописовим. Після 8-байтового префікса версії, де і мажорна, і мінорна версії дорівнюють 4, потік є XML-дескриптором у UTF-8. Елемент keyData оголошує шифр (AES), режим зчеплення (ChainingModeCBC), хеш (SHA512), довжину ключа в бітах, розмір блока та сіль у 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, він спершу пробує стандартне шифрування 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;

Запасний шлях для незашифрованого входу важить більше, ніж здається. Пакетному імпортеру, який завжди викликає OpenEncrypted, не потрібне розгалуження в місці виклику: файли, які ніколи не захищали, завантажуються рівно як раніше, а файли, що надходять зашифрованими, дешифруються на місці й далі подаються звичайному завантажувачу ZIP як потік у пам'яті. Тестувати треба один шлях коду, а не три

Як пароль стає ключем AES?

Шифрування Agile ніколи не використовує пароль напряму. HotXLS спершу обчислює ітерований хеш: початковий дайджест є SHA-512 над сіллю пароля, зчепленою з байтами пароля в UTF-16LE, а далі дайджест перехешовується spinCount разів, і кожен раунд додає спереду 32-бітний лічильник ітерацій у little-endian до попереднього дайджеста. З типовим для Excel лічильником обертів 100 000 це сто тисяч послідовних викликів SHA-512 на кожну спробу пароля, і в цьому вся суть. Лічильник обертів є гальмом для перебору: законному викликачеві він коштує кілька мілісекунд один раз, а нападнику зі словником - ті самі кілька мілісекунд на кожну окрему здогадку

Як HotXLS виводить ключі шифрування Agile у Delphi: пароль прокручується через 100 000 раундів SHA-512, а потім три фіксовані 8-байтові блокові ключі дають ключі верифікатора та пакета
Пароль ніколи нічого не дешифрує напряму: ланцюжок SHA-512 із лічильником обертів живить три хеші блокових ключів, які дають ключі верифікатора й розгортають випадковий ключ пакета
// Ітерований хеш пароля за [MS-OFFCRYPTO]:
//   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);            // лічильник ітерацій, little-endian
    Move(Result[0], buf[4], 64);   // попередній дайджест
    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 діє тоді, коли сіль пароля розширюють до розміру блока для використання як вектор ініціалізації CBC

Перевірка пароля й пастка з обрізанням до saltSize

HotXLS звіряє пароль ще до того, як торкнеться пакета, користуючись парою верифікатора з дескриптора. Він дешифрує encryptedVerifierHashInput першим виведеним ключем, хешує результат через SHA-512, дешифрує encryptedVerifierHashValue другим виведеним ключем і порівнює два дайджести байт у байт. Розбіжність означає, що пароль неправильний, і про це повідомляється як про окремий результат, а не як про перекручену робочу книгу, і, що критично, це означає, що тіло пакета ніколи не дешифрується поганим ключем, тож немає сценарію, у якому хибний пароль дає правдоподібні на вигляд зіпсовані дані

Тут є деталь специфікації, у якій легко помилитися. [MS-OFFCRYPTO] §2.3.4.13 визначає верифікатор як saltSize байтів випадкових даних, де saltSize є довжиною солі шифрувальника ключа, а не розміром блока шифру. Оскільки шифротекст AES-CBC вирівняний по блоках, дешифрований вхід верифікатора повертається доповненим до кратного 16 байтам, і його треба обрізати назад до saltSize перед хешуванням. Excel завжди пише saltSize, що дорівнює blockSize, обидва по 16, тож реалізація, яка пропускає обрізання, проходить кожен тест проти справжнього виводу Excel, а потім падає на першому ж файлі від виробника, який обрав іншу довжину солі. HotXLS обрізає до довжини солі, бо саме це насправді каже специфікація, а збіг цих двох значень на практиці є випадковістю, а не контрактом

Як дешифрується 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 кожного 4096-байтового сегмента AES-CBC із солі keyData та індексу сегмента
Кожен сегмент AES-CBC на 4096 байтів дешифрується незалежно під власним IV із солі та індексу, а результат обрізається до оголошеного розміру відкритого тексту

Оголошений розмір відкритого тексту виконує останню частину роботи. Вивід AES-CBC вирівняний по блоках, тож останній сегмент несе до 15 байтів доповнення, які не є частиною документа; дешифрований буфер обрізається до префікса розміру, і результатом є рівно той ZIP-файл .xlsx, який зашифрував Excel. HotXLS перевіряє префікс проти фактичної довжини потоку перед дешифруванням, тож обрізане завантаження чи підроблене поле розміру падає чисто, а не виходить за межі

Повідомлення про помилки та чесні межі

Режими відмов навмисно тримають окремо. Хибний пароль піднімає виняток із явним повідомленням про неправильний пароль, зумовленим розбіжністю верифікатора, тож інтерфейс може запропонувати користувачеві спробувати ще раз. Контейнер 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
      // Піднімається і для хибного пароля, і для непідтримуваної
      // схеми; E.Message каже, що саме, тож логуйте його буквально
      // й пропонуйте повтор пароля лише у випадку хибного пароля
      RejectUpload(FileName, E.Message);
  end;
end;

Межі варто назвати прямо. HotXLS читає дескриптори Agile, які оголошують AES у режимі CBC із SHA-512, а це покриває те, що Excel від 2010 до Excel 365 насправді пише, в усіх трьох розмірах ключа. Дескриптори, що оголошують інші шифри чи алгоритми хешування, відхиляються, а не вгадуються, і шифрувальники ключа на основі сертифікатів не розглядаються - лише шифрувальник ключа за паролем. На боці запису HotXLS наразі створює стандартне шифрування, а не Agile, і ця різниця має значення, якщо інструменти нижче за течією перевіряють схему; подробиці є в статті про запис виводу XLSX під захистом AES

Захищені паролем завантаження перестають бути особливим випадком, щойно завантажувач трактує шифрування як частину формату файлу, а не як виняток із нього. Точка входу OpenEncrypted, виведення ключа через SHA-512 з лічильником обертів і описаний тут сегментований конвеєр AES-CBC постачаються у складі HotXLS Delphi Excel Component поряд із рештою його нативного рушія читання й запису XLS та XLSX для Delphi та C++Builder