ZIPファイルのAES暗号化をPHPで実装する

# ZIPファイルのAES暗号化をPHPで実装する

## AES暗号化について

ZIPには元々`Traditional PKWARE Encryption`(通称:`ZipCrypto`)とよばれる暗号化機能があり、WindowsやMacOSXの標準で現在でも使われている。[PKZIPのAPPNOTE.txt]( https://pkware.cachefly.net/webdocs/casestudies/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のサイト]( https://www.winzip.com/win/en/aes_info.html ) や [Dr. Brian Gladmanのサイト]( http://brg.a2hosted.com/oldsite/cryptography_technology/fileencrypt/index.php )にほとんど書いてある。
1点、aes-256-ctrに渡すnonce(IV)の情報がわかりにくかった。調べた結果、nonceには1から始まるブロック番号を渡せば良い。ブロック番号は16byte毎に1繰り上がり、nonceは16byteのリトルエンディアンバイトオーダーなので "01000000...", "02000000..." のようになる。詳しくは以下の実装例をみればわかるはず。

## PHPでの実装例

```php:aeszipsample.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) を利用すれば少し簡単に記述できる。

```php
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
<?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にしないといけない。開けなくなるとか壊れるとかそういうことは無いのだが。


参考: 
* [A Password Based File Encryption Utility]( http://brg.a2hosted.com/oldsite/cryptography_technology/fileencrypt/index.php )
* [AES Encryption Information:Encryption Specification AE-1 and AE-2]( https://www.winzip.com/win/en/aes_info.html )
* [PKZIP/SecureZIP による暗号化アルゴリズム設定]( https://www.xlsoft.com/jp/blog/blog/2017/12/09/post-2724/ )