ZIPファイルのAES暗号化をPHPで実装する
AES暗号化について
ZIPには元々Traditional PKWARE Encryption
(通称:ZipCrypto
)とよばれる暗号化機能があり、WindowsやMacOSXの標準で現在でも使われている。PKZIPのAPPNOTE.txtにはより強力な暗号化の仕様(Strong Encryption Specification
)が載っているが、これをサポートしているアーカイバは少ない。開発元のWinZipやPHP7.2以降のZipArchive・7-zipでは、AESを利用したAE-x encryption
とよばれる暗号化処理を実装している。以降でその仕様をまとめ、PHPによる実装例を紹介する。
AE-x encriptionの概要
- AESのカウンターモードを使う(aes-256-ctr等)
- 暗号化キーと検証キーにPBKDF2を使う
- ファイル検証にHMAC_SHA1を使う
- ファイル名やファイルサイズが隠蔽されるわけではない
- PKZIPの
Strong Encryption Specification
と違って仕様が公開されおり再実装が可能 - とはいえサポートするアーカイバは限られる
実装の詳細は、WinZipのサイト や Dr. Brian Gladmanのサイトにほとんど書いてある。
1点、aes-256-ctrに渡すnonce(IV)の情報がわかりにくかった。調べた結果、nonceには1から始まるブロック番号を渡せば良い。ブロック番号は16byte毎に1繰り上がり、nonceは16byteのリトルエンディアンバイトオーダーなので "01000000...", "02000000..." のようになる。詳しくは以下の実装例をみればわかるはず。
PHPでの実装例
<?php
/**
* ZIP圧縮・AES暗号化のサンプル
*/
function compress($data, $password) {
$data = gzdeflate($data);
$salt = openssl_random_pseudo_bytes(16);
$dk = openssl_pbkdf2($password, $salt, 66, 1000);
$enckey = substr($dk, 0, 32); // 先頭32Bは、AES暗号化のキー
$authkey = substr($dk, 32, 32); // 次の32Bは、HMAC-SHA1検証のキー
$pwd_verify = substr($dk, 64, 2); // 末尾2Bは、パスワード検査
$encrypted = '';
for($i=$ctr=0,$len=strlen($data); $i<$len; $i+=16) {
// 16byteずつ処理
$nonce = pack('VVVV', ++$ctr, 0, 0, 0); //nonceは16バイト毎に1増加
$encrypted .= openssl_encrypt(substr($data, $i, 16), 'aes-256-ctr', $enckey, OPENSSL_RAW_DATA, $nonce);
}
$authcode = substr(hash_hmac('sha1', $encrypted, $authkey, true), 0, 10); //暗号化データから検証コードを生成
return $salt . $pwd_verify . $encrypted . $authcode;
}
function zipdatetime($time) {
list($y,$m,$d,$h,$i,$s) = explode('/', date('Y/m/d/H/i/s', $time));
$date = ($y - 1980) << 9 | ($m - 0) << 5 | ($d - 0);
$time = (($h - 0) << 11) | ($i - 0) << 5 | ceil(($s-0)/2.0);
return array($date, $time);
}
function local_file_header($opt) {
$dt = zipdatetime($opt["mtime"]);
$exfield = pack('vvvvCv', 0x9901, 7, 2, 0x4541, 3, 8); //AE-2 strength=3(256bit) deflated
return pack('VvvvvvVVVvv',
0x04034b50, //file header signaure
51, //version needed extract(minimum): 5.1
2049, //general purpose bit flag: encrypted|utf-8
99, //compression method: aes
$dt[1], //last modified time
$dt[0], //last modified date
0, //CRC-32
$opt['compsize'], //compressed size
$opt['filesize'], //uncompressed size
strlen($opt['filename']), //file name length(n)
strlen($exfield)) . //extra field length(m)
$opt['filename'] . //file name
$exfield; //extra field
}
function central_directory($opt) {
$dt = zipdatetime($opt["mtime"]);
$exfield = pack('vvvvCv', 0x9901, 7, 2, 0x4541, 3, 8); //AE-2 strength=3(256bit) deflated
return pack('VvvvvvvVVVvvvvvVV',
0x02014b50, //cd signature
51, //version made by: 5.1
51, //version needed extract(minimum): 5.1
2049, //general purpose bit flag: encrypted|utf-8
99, //compression emthod: aes
$dt[1], //last modified time
$dt[0], //last modified date
0, //CRC-32
$opt['compsize'], //compressed size
$opt['filesize'], //uncompressed size
strlen($opt['filename']), //file name length(n)
strlen($exfield), //extra field length(m)
0, //file comment length(k)
0, //disk where cd starts
0, //internal file attributes
0, //external file attributes
$opt['offset']) . //offset of local file header
$opt['filename'] . //file name
$exfield; //extra field
}
function end_of_central_directory($opt) {
return pack('VvvvvVVv',
0x06054b50, //eocd signature
0, //number of the disk", "2B"),
0, //disk where cd starts", "2B"),
$opt['total'], //number of cd on the disk", "2B"),
$opt['total'], //total number of cd", "2B"),
$opt['cdsize'], //size of cd(bytes)
$opt['offset'], //offset of start of cd
0); //comment length (n)
}
function save($output, $files, $password) {
$offset = 0;
$cd_all = '';
$fh = fopen($output, 'wb');
foreach($files as $filepath) {
$filesize = filesize($filepath);
$encrypted = compress(file_get_contents($filepath), $password);
$opt = array(
"filename" => $filepath,
"filesize" => $filesize,
"compsize" => strlen($encrypted),
"mtime" => filemtime($filepath)
);
fwrite($fh, ($hdr = local_file_header($opt)));
fwrite($fh, $encrypted);
$cd = central_directory($opt + array("offset" => $offset));
$offset += strlen($hdr) + strlen($encrypted);
$cd_all .= $cd;
}
fwrite($fh, $cd_all);
fwrite($fh, end_of_central_directory(array(
"cdsize" => strlen($cd_all),
"offset" => $offset,
"total" => count($files))));
fclose($fh);
return $offset + strlen($cd_all) + 22;
}
if (isset($argv) && realpath($argv[0]) === __FILE__) {
$output = 'output.zip';
$files = array('sample.txt');
$password = 'mypassword';
$written = save($output, $files, $password);
echo "saved: " . $output . " (" . $written . "bytes)";
}
適当な sample.txt を用意して php aeszipsample.php
を実行すれば、AES暗号化された output.zip が作成される。
自作の[github - Rezipe] (https://github.com/wealandwoe/Rezipe) を利用すれば少し簡単に記述できる。
require_once 'rezipe.php';
$output = 'output.zip';
$files = array('sample.txt');
$password = 'mypassword';
$zip = new \Rezipe\Zip();
$zip->compress = true;
$zip->is_utf8 = true;
$zip->aescrypto = $password;
foreach($files as $file) {
$zip->add_file($file, $file);
}
$zip->save($output);
echo "saved: " . $output. " (" . $zip->bytes() . "bytes)";
PHP7.2以降(libzip1.2以上)ならZipArchiveがAES暗号化に対応しているので
<?php
$zip = new ZipArchive();
if ($zip->open('output.zip', ZipArchive::CREATE) === TRUE) {
$zip->setPassword('mypassword');
$zip->addFile('sample.txt');
$zip->setEncryptionName('sample.txt', ZipArchive::EM_AES_256);
$zip->close();
echo "Ok\n";
} else {
echo "KO\n";
}
?>
で良い。ただ、ZipArchiveの作るAES暗号化ファイルはLocalFileHeaderにCRC-32が残っている。実はこれは間違いで、AE-x encryption(AE-2)ならば0にしないといけない。開けなくなるとか壊れるとかそういうことは無いのだが。
参考: