あなたがワークブックを作成してパスワードで暗号化し、そのファイルを同僚に渡してExcelで開かせるとします。Excelはパスワードを要求し、同僚が入力すると、Excelはそれを承認します。ここまでは暗号化が正しく機能しているように見えます。しかしその後、Excelはファイルが破損しているため開けないというダイアログを表示するか、無意味なセルばかりのシートを開きます。パスワードは正しかったのに、ファイルは破損しています。これはOffice暗号化における最も混乱を招く失敗モードです。パスワードが正しいと伝える部分とデータを保持する部分が、2つの異なる処理によって保護されているため、一方を正しく処理できても、もう一方が保証されるわけではないからです
ここで述べる2つの罠は、まさにこの形をしています。どちらも検証器は通るのに本体は通らず、実在しないパスワードや鍵導出の問題を探し回らされます。本当の故障は下流、パッケージバイトの変換のしかたにあります。2つの罠は独立していて、1つは AES 経路、もう1つは RC4 経路のものですが、診断の難しさは共有しています。「半分正しい結果」が一番読みにくい種類の結果である理由を見ておく価値はあります
パスワードの通過が本体の整合性を何も証明しない理由
現代の暗号化されたXLSXが使用するフォーマットはECMA-376標準暗号化(Standard Encryption)であり、2つの暗号化されたオブジェクトが並んで格納されます。1つはEncryptionVerifier(暗号化検証用オブジェクト)で、ランダムな値とそのハッシュ値を保持する小さなブロックで、パスワードから導出された鍵で暗号化されています。もう1つはEncryptedPackage(暗号化されたパッケージ)で、同じ鍵で暗号化されたワークブックのzipコンテナ全体です。検証用のオブジェクトが存在する理由は、リーダーが数メガバイトの本体の処理に労力を費やす前に、パスワードを確認できるようにするためです。検証用データを復号し、ランダムな値をハッシュ化し、保存されているハッシュと比較して、一致すればパスワードが正しいと判断します
罠は、検証用データとパッケージが、別々のバッファに対して別々の呼び出しによって暗号化される点です。正しく導出された鍵は、パッケージがその後どうなろうとも、検証用データを正しく復号します。したがって、鍵導出が正しくてもパッケージの変換が誤っている場合、Excelは検証用データからパスワードを承認しつつ、本体で失敗します。症状は「正しいパスワード、壊れたファイル」として表示されるため、調査の矛先はパスワードパスに向かいますが、そこは壊れていない部分です。同様の分離はレガシーなRC4のケースでも機能します。すなわち、検証ハッシュが最初にチェックされ、本体がずれて非同期になってもそのチェックは影響を受けません
罠その1:AESはECBであってCBCではない
[MS-OFFCRYPTO] §2.3.4.15は、標準暗号化がパッケージをECB(Electronic Codebook)モードのAESで暗号化することを規定しています。パディングされたパッケージのすべての16バイトブロックは、同じ鍵で独立して暗号化されます。ブロック間のチェーン処理(連鎖)はなく、初期化ベクトル(IV)もありません。これは、通常ECBが回避される現代の基準から見ると珍しい選択ですが、相互運用性を確保する上で仕様を勝手に推測することは許されません。ExcelはパッケージをECBとして復号するため、ジェネレータ(生成器)もそれをECBとして暗号化しなければ、両者は一致しません
誘惑される間違いは、すべてゼロの初期化ベクターで AES を CBC モードにしてパッケージを暗号化することです。なぜそれが「ほぼ」動くのか、そしてなぜ「ほぼ」が最悪の着地点なのかを説明します。CBC では、最初の平文ブロックは暗号化の前に IV と XOR されます。IV がすべてゼロなら、その XOR は何も変えないので、ゼロ IV の CBC の最初のブロックは ECB とまったく同じ暗号文になります。2ブロック目以降は CBC が前の暗号文ブロックを次のブロックに混ぜ込むので、最初以降のすべてのブロックが ECB からずれていきます
これをファイル構造に重ねてみましょう。パッケージレイアウトは先頭に8バイトのリトルエンディアン長プレフィックスを置くので、Excel が最初にチェックする部分は最初の1〜2ブロックの中にあります。最初のブロックがたまたま一致していれば、最初期の検証は通るのに、その後のすべてのブロックはノイズに復号されます。モード名が明らかになれば、治し方は単純です。16バイトごとのブロックを ECB で暗号化し、チェーンしない。エンジンではXlsEncryptStdPackageがパディング済みバッファを16バイト刻みで歩き、それぞれにAESEncryptECB128Blockを呼びます。これは検証器ブロックですでに使われているのと同じプリミティブです。ソースにはそのループの位置に、規則を率直に述べたコメントがあります。ゼロ IV の CBC が ECB と一致するのは最初のブロックだけなので、パッケージの残りはゴミに復号され、Excel は拒否する、と
var
Book: TXLSXWorkbook;
begin
Book := TXLSXWorkbook.Create;
try
Book.Open('report.xlsx');
// SaveAsEncryptedはワークブックをシリアライズしてから、
// ECMA-376標準暗号化パイプラインを実行する: [MS-OFFCRYPTO] 2.3.4.15に
// 従い、パッケージ全体をAES-128 ECBで暗号化する。成功時は1を返す。
if Book.SaveAsEncrypted('report_secure.xlsx', 'S3cret!') <> 1 then
raise Exception.Create('Encryption failed');
finally
Book.Free;
end;
end;
罠その2:RC4の再キーが歩調をずらす
レガシーな.xlsパスはRC4 CryptoAPIスキームを使用しており、そのルールは本質的に異なります。[MS-OFFCRYPTO] §2.3.6は、暗号が1024バイトのブロック境界ごとに再生成(re-keyed)されることを規定しています。ストリームは1024バイト of blockに分割され、ブロック番号0、1、2などに対して新しいRC4キーが導出され、各ブロック内でキーストリームはバイトごとに連続して消費されます。2つの不変条件を同時に維持する必要があります。境界ごとにキーを再生成すること、およびブロック内でギャップ(隙間)なしにキーストリームを消費することです。RC4はストリーム暗号であるため、そのキーストリームは単一の整列されたシーケンスであり、引き出されるn番目のバイトは、それまでに引き出されたバイト数によって決定されます。復号は同じシーケンスに対する同じXOR処理であるため、生成側と消費側は、全く同じ位置で全く同じバイトを引き出す必要があります
これが難しさの全部です。ストリーム暗号には再同期がありません。キーストリームを1バイト無駄にすると、その後のすべてのバイトが間違ったキーストリームバイトと XOR され、誤りは自己修復しません。ブロックの終わりまで、そして実行位置が一度ずれたらその後のすべてのブロックへ、連鎖していきます。小さな帳簿づけのミスが、まさにそれを引き起こします。センチネル値の-1から始まるブロックカウンターと、カウンターがすでに現在のブロックに一致しているものと仮定するスキップルーチンを思い浮かべてください。そのセンチネルから出発すると、再キーを行い、消費されるはずのなかった1024バイトまるごとのキーストリームブロックを走らせ、その過程で残りカウントをマイナスに追い込みます。この時点から、復号器はまる1ブロックぶん位相がずれます。どれよりも前にチェックされる検証器はまだ通るので、パスワードは正しいのに見え、すべてのデータセルはゴミとして出てきます
HotXLS では、このロジックはTXLSDecrypterRC4にあります。SkipとDecryptは1つのループを共有します。実行位置が新しいブロックへ跨るときだけ再キーする。ブロックインデックスは位置をREKEY_BLOCK_SIZE(1024)で割ったもので、その後は現在のブロックの残りまでだけを消費し、それ以上は消費しません。MakeKeyはブロックインデックスを渡されて呼ばれ、古いインデックスやセンチネルを渡されることはありません。位置は処理したバイト数ぴったりだけ進むので、SkipとDecryptは生成側と位相を合わせ続けます。教訓は最小の単位にあります。ストリーム暗号において、1バイトの無駄は小さな誤りではなく、下流すべての完全な損失です
var
Book: TXLSWorkbook;
begin
Book := TXLSWorkbook.Create;
try
// RC4 または RC4 CryptoAPI で保護されたレガシーの .xls は
// Open の中で復号されます。パスワードを渡せば、ストリーム復号器が
// 再キーの間隔を生成側に合わせて保ちます。
Book.Open('legacy.xls', 'S3cret!');
// ここでセルを読み出す
finally
Book.Free;
end;
end;
凍結された仕様との相互運用はバイト単位での一致
2つの罠は同じ根元的原則に帰着します。設計上の取捨選択の付け方が変わるものなので、独立して述べておく価値があります。出力の消費者が、こちらでは変えられない固定の外部プログラムであるとき、暗号モードと再キーの間隔は、最適化したり簡略化したりしてよい実装詳細ではありません。それらはワイヤ契約の一部です。Excel は、その選択が気に入ろうと気に入るまいと、ECB で復号し、1024バイト境界で再キーを行います。こちらの仕事は、その厳密な手順のもとで元どおりに復号されるバイトを作り出すことだけです。より現代的なモード、無害に見える IV、自然な場所から始まるカウンター。そのどれも、読み手の期待から外れた瞬間に欠陥になります。固定化された仕様との相互運用に「ほぼ」はありません。バイト単位で一致するか、壊れているかのどちらかです
これが、検証用データ単体では貧弱な動作テスト(スモークテスト)にしかならない理由でもあります。検証用データは鍵の導出が機能していることを教えてくれますが、それは必要条件であっても十分条件からは程遠いです。暗号化されたファイルを開いてパスワードの承認のみを確認するテストは、本体が読み取れない状態であっても成功を報告します。実際のテストでは、パッケージを復号して復元されたバイト列を元の入力と比較するか、ワークブックを暗号化および復号のループに通してセルを読み戻します。検証用データはパスワードを証明するだけであり、暗号化を証明できるのは本体だけです
保護されたワークブックを読み書きするサポートされた方法
公開面は小さくすっきりしています。パスワード保護された最新ワークブックを書くには、TXLSXWorkbookを組み立てるか開いてから、ファイル名とパスワードを添えてSaveAsEncryptedを呼びます。ワークブックを直列化し、前述の Standard Encryption パイプラインを実行して、成功時に1を返します。SaveAsEncryptedAgileは同じ引数を取り、Excel 2010 以降がデフォルトで使う Agile スキーム、つまり SHA-512 の鍵導出と AES-256 のパッケージ暗号化を書き出します。読む側では、まずCanReadEncryptedでファイルが Compound File コンテナかを調べてから分岐します。OpenEncryptedは Standard と Agile の両パッケージを復号し、平文のファイルはOpenへフォールバックします。パスワードが違うときやコンテナが別のスキームを使っているときはEXlsxEncryptionNotImplementedを送出します。レガシーの .xls ファイルも Compound File なので、こちらはパスワード付きでTXLSWorkbook.Openへ渡します。上述のモード処理と再キーループは、これらの呼び出しの下にあります。パスワードとファイル名を渡せば、エンジンが仕様への適合を引き受けてくれます
var
Book: TXLSXWorkbook;
begin
Book := TXLSXWorkbook.Create;
try
Book.Open('quarterly.xlsx');
Book.SaveAsEncrypted('quarterly_locked.xlsx', 'P@ssphrase');
// 消費者側で再度開く
Book.OpenEncrypted('quarterly_locked.xlsx', 'P@ssphrase');
finally
Book.Free;
end;
end;
保護された出力の形状、EncryptionInfoストリーム、検証用ブロック、およびパッケージレイアウトについては、AES保護されたXLSX出力のチュートリアルでカバーされています。シートレベルのロックという個別の問題や、保護がページ設定や印刷とどのように対話するかについては、保護、ページ設定、および印刷に関する記事を参照してください。どちらも、このブログの他の場所で扱われている読み込み、書き込み、およびレンダリングAPIと並んで、DelphiおよびC++Builder向けのHotXLS Delphi spreadsheet componentの一部として提供されている、ここで解説した暗号化パスをベースにしています