見出し画像

PHPでWeChatシステムに対応した暗号化と復号化を実装する

WeChatシステムからイベントを受け取る設定をした際に「消息加解密方式」を「安全模式」にした場合、WeChatシステムからは暗号化されたイベントデータが飛んで来るようになります。また、そのイベントへ応答する場合も返送するデータを暗号化して送らなければなりません。
こちらで紹介したようにテンセントではPHP、Java、C++、Python、C#の5つの言語でサンプルコードを用意していますが、その中でPHPは2022年3月時点ではlibmcryptを使ったサンプルコードしかありません。
しかしPHPはバージョン7.2でlibmcryptへの対応を終了してしまいましたので、普通にインストールしたPHPではテンセントのサンプルコードは動かなくなってしまいました。

この機能は PHP 7.1.0 で 非推奨 となり、 PHP 7.2.0 で削除 されました。

PHPマニュアルのmcryptのページより

ここではPHPのOpenSSLモジュールを使ってWeChatシステムと会話する方法について私が色々と試行錯誤した結果を共有したいと思います。
なお、ここで取り扱っている方法は「色々試して何とか会話出来るようになった」レベルのものですので、暗号技術の使い方として間違った部分もあるかも知れません。本稿を何かの参考になさる場合はその点だけご了承ください。

本稿における共通の前提条件

WeChatシステムから送られて来る暗号データを復号する為に必要な要素は、管理画面での設定時に決めた値やXMLデータやGET文字列の中にある値になります。暗号化・復号化の中で触れるこれらの要素について以下のように定義しておきます。

  • トークン
    WeChatオフィシャルアカウントプラットフォームの管理画面でイベント受信設定をした際に「令牌(Token)」で設定した任意の文字列です。
    管理画面での設定方法はこちらの記事で説明しています。

  • 暗号鍵文字列
    こちらもWeChatオフィシャルアカウントプラットフォームの管理画面で設定した「消息加解密密钥(EncodingAESKey)」になります。43文字の半角英数字です。

  • タイムスタンプ
    WeChatシステムがイベント通知のリクエストを送ってくる際にGET文字列「timestamp」として付与して来るデータです。

  • Nonce
    タイムスタンプと同様にGET文字列「nonce」として送られて来るデータです。

  • データ署名
    タイムスタンプと同様にGET文字列「msg_signature」として送られて来るデータです。GET文字列には「signature」という似た名前のデータが存在しますが、「signature」はリクエストそのものの署名で暗号化データに対する署名ではありませんので、混同しないように注意しましょう。

  • 暗号文
    暗号化されたイベントデータです。POSTされて来るXMLデータの要素「Encrypt」です。

  • AppID
    公式アカウント自身のAppIDを暗号化するデータに含める必要があります。自分のAppIDの調べ方はこちらの記事に書いてあります。

暗号化されたデータの復号とデータの取り出し

復号の大まかな手順は以下の通りです。

  1. 署名チェック
    タイムスタンプ
    Nonceトークン暗号文をアルファベットの昇順に並べ替えてから連結した文字列をSHA-1でハッシュ化し、その値とデータ署名を比較して等しいことを確認する

  2. 共通鍵の生成
    暗号鍵文字列の末尾に「=」を追加したうえでBase64でデコードして共通鍵を生成する

  3. 復号
    共通鍵を使って暗号文をAES-256-CBCで復号する

  4. XMLデータの文字列長を取得する
    復号したバイナリデータの先頭から数えて17~20バイトまでの4バイトを取り出し、ビッグエンディアンのunsigned longとしてアンパックしてXMLの文字列長を取得する

  5. XMLデータを取り出す
    復号したバイナリデータの先頭から数えて21バイト目から手順4で取得したXMLの文字列長分のデータを取り出すとこれがデータXML文字列となる

上記手順をPHPのOpenSSL関数を使ったコードにすると以下のようになります。

$token = "XXXXX"; // トークン
$encrypt_key = "YYYYYYYYY"; // 暗号鍵文字列
$timestamp = "1648272120"; // タイムスタンプ
$nonce = "1234567"; // Nonce
$msg_sign = "signsignsign"; // データ署名
$msg_encrypt = "abcdabcd"; // 暗号文

// 【1】署名チェック
// タイムスタンプ、Nonce、トークン、暗号文をアルファベットの昇順に並べ替える
$arr = array($timestamp,$nonce,$token,$msg_encrypt);
sort($arr,SORT_STRING);
// 並べ替えたら連結してSHA1でハッシュ化
$hash_str = sha1(implode($arr));

if($hash_str == $msg_sign){
   // ハッシュ化した文字列とデータ署名が等しかった

   // 【2】共通鍵の生成
   // 暗号鍵文字列に「=」を追加してBase64でデコードする
   $AESKey = Base64_Decode($encrypt_key . "=");

   // 【3】復号
   // 共通鍵を使って「AES-256-CBC」で復号する
   if($decrypted = openssl_decrypt($msg_encrypt, 'aes-256-cbc', $AESKey ,OPENSSL_ZERO_PADDING)){
      // 復号成功
      // 【4】XMLデータの文字列長を取得する
      $length = unpack("N", substr($decrypted, 16, 4))[1];

      // 【5】XMLデータを取り出す
      $xml_content = substr($decrypted, 20, $length);
   }
}

返信するデータの暗号化

安全模式」の場合はイベントに対して応答する場合もデータを暗号化する必要があります。暗号化の大まかな手順は以下の通りです。

  1. 応答するXMLデータの作成
    応答するXMLデータを作成します。詳しくはこちらの記事をご参照ください。

  2. 16バイトのランダム文字列の生成
    初期ベクトルを使って暗号文のワンパターン化を防いでいるので、WeChatシステムは返信したデータの先頭16バイトは読み飛ばします。その為に16バイトのランダムな文字列を用意しておきます。

  3. 応答するXMLデータの文字列長をバイナリ文字列にパックする
    WeChatシステムが復号したデータのどこまでがXMLデータであるかを判別する為の情報としてXMLデータの文字列長を32 ビットのビッグエンディアンバイトオーダーでバイナリ文字列にパックしたものを用意しておきます。

  4. 暗号化するデータの生成
    手順2で生成したランダム文字列、手順3で生成したバイナリ文字列、手順1で生成したXML、自分のAppIDの順で連結した文字列を生成します。

  5. データのパディング
    手順4で生成したデータをPKCS#7でパディングする。だたし、データ長が32バイトの倍数になるようにパディングする。16バイトではなく32バイトである理由は後述します。

  6. 共通鍵の生成
    暗号鍵文字列の末尾に「=」を追加したうえでBase64でデコードして共通鍵を生成します。

  7. 初期ベクトルの生成
    WeChatシステムは初期ベクトルを使って暗号文のワンパターン化を防いでいるので、適当な初期ベクトルを用意しておきます。

  8. 暗号化
    手順6で生成した共通鍵と手順7で生成した初期ベクトルを使って手順5でパディング済みのデータを暗号化します。

  9. 検証用署名の生成
    Nonceタイムスタンプトークン、そして手順8で生成した暗号文をアルファベット順に並べ替えたうえで連結してSHA1でハッシュ化しておきます。

  10. XMLに格納する
    最後に手順8で生成した暗号文、手順9で生成した検証用の署名、タイムスタンプNonceを所定のXMLフォーマットに格納して完了です。暗号化方式が「安全模式」に設定されている公式アカウントはこのXMLをWeChatシステムへ返送すればOKです。

上記手順をPHPのOpenSSL関数を使ったコードにすると以下のようになります。

$token = "XXXXX"; // トークン
$encrypt_key = "YYYYYYYYY"; // 暗号鍵文字列
$timestamp = "1648272120"; // タイムスタンプ
$nonce = "1234567"; // Nonce
$appid = "appAAAAAAA"; // 公式アカウントのAppID

// 【1】応答するXMLデータの作成
$openid = "oOAAAAAAAA";
$create_time = time();
$message = <<<EOM
<xml>
  <ToUserName><![CDATA[{$openid}]]></ToUserName>
  <FromUserName><![CDATA[{$appid}]]></FromUserName>
  <CreateTime>{$create_time}</CreateTime>
  <MsgType><![CDATA[text]]></MsgType>
  <Content><![CDATA[フォローありがとうございます!]]></Content>
</xml>
EOM;

// 【2】16バイトのランダム文字列の生成
$rand_str = sprintf("%08d",rand(0,99999999)) . sprintf("%08d",rand(0,99999999));
// 【3】応答するXMLデータの文字列長をバイナリ文字列にパックする
$msg_length = pack("N",strlen($message));
// 【4】暗号化するデータの生成
$data = $rand_str . $msg_length . $message . $app_id;
// 【5】データのパディング
$block_size = 32; // パディングするブロック長は何故か分からないが32バイト
$data_length = strlen($data);
$padding = $block_size - ($data_length % $block_size); // 32バイトの倍数にする為に埋める必要のある文字数を計算
$data .= str_repeat(chr($padding),$padding); // PKCS#7と同じ要領でパディングする

// 【6】共通鍵の生成
$AESKey=Base64_Decode($encrypt_key . "=");
// 【7】初期ベクトルの生成
$iv = openssl_random_pseudo_bytes(openssl_cipher_iv_length('aes-256-cbc'));
// 【8】OpenSSL関数で暗号化
$enc_msg = openssl_encrypt($data, 'aes-256-cbc', $AESKey, OPENSSL_ZERO_PADDING, $iv);
	
// 【9】検証用署名の生成
$arr = array($token,$timestamp,$nonce,$enc_msg);
sort($arr,SORT_STRING);
$msg_sign = sha1(implode($arr));
	
// 【10】XMLに格納する
$xml_data = <<<EOM
<xml>
<Encrypt><![CDATA[{$enc_msg}]]></Encrypt>
<MsgSignature><![CDATA[{$msg_sign}]]></MsgSignature>
<TimeStamp>{$timestamp}</TimeStamp>
<Nonce><![CDATA[{$nonce}]]></Nonce>
</xml>
EOM;

WeChatシステムのパディングは変?

さて、テンセントのドキュメントには「暗号化アルゴリズムにはAESを採用しています」という記述があります。

消息加密解密技术方案基于 AES 加解密算法来实现

テンセント公式ドキュメント

また、以下の記述から暗号モードはCBCを使っており、256ビット長の鍵を使っているようです。(いわゆるAES-256-CBC)

AES 采用 CBC 模式,秘钥长度为 32 个字节(256 位),数据采用 PKCS#7 填充

テンセント公式ドキュメント

ただ、それに続いて気になる記述が…

K 为秘钥字节数(采用 32),Buf 为待加密的内容,N 为其字节数。Buf 需要被填充为 K 的整数倍。

テンセント公式ドキュメント

(翻訳文)
Kを鍵の長さのバイト数とします(32バイト)、bufを暗号化しようとするコンテンツ、Nをそのデータ長とします。bufはKの倍数になるようにパディングされている必要があります。

さて、私は暗号化技術の専門家ではないので一般的な知識しか持ち合わせていませんが、AESのブロック長は128ビット、つまり16バイトのはずです。
従ってパディングについても16バイトの倍数になるようになされるべきだという認識ですが、WeChatシステムでは32バイトでパディングを行っているようです。
実際にPHPのopenssl_decrypt関数で「OPENSSL_ZERO_PADDING」を指定せずにパディングの除去をOpenSSLに任せると復号に失敗してしまう場合があります。それはWeChatシステムから送られて来た暗号データのデータ長に関係があるのですが、データ長を32で除算して余りが0又は17~31の時に発生します。
ちなみにテンセントのサンプルコード「pkcs7Encoder.php」にも以下のような記述があります。
※プログラムにある中国語のコメントは私が日本語へ翻訳してあります

class PKCS7Encoder
{
	public static $block_size = 32;

	/**
	 * 暗号化するデータをパディングする
	 * @param $text 暗号化されるテキスト
	 * @return パディング済みのテキスト
	 */
	function encode($text)
	{
		$block_size = PKCS7Encoder::$block_size;
		$text_length = strlen($text);
		//パディングする桁数を計算する
		$amount_to_pad = PKCS7Encoder::$block_size - ($text_length % PKCS7Encoder::$block_size);
		if ($amount_to_pad == 0) {
			$amount_to_pad = PKCS7Encoder::block_size;
		}
		//パディングする文字列を取得
		$pad_chr = chr($amount_to_pad);
		$tmp = "";
		for ($index = 0; $index < $amount_to_pad; $index++) {
			$tmp .= $pad_chr;
		}
		return $text . $tmp;
	}

【以下略】

データ長が32バイトの倍数になるようにパディングするという形式が存在するのかどうかは分かりません。しかし少なくともPHPのopenssl_decrypt関数はデフォルトだと16バイトの倍数となるようにパディングされている事を期待していますし、openssl_encrypt関数は16バイトの倍数になるようにパディングしてしまいます。(それが正しいと思うのですが…)
という事で私はWeChatシステムと暗号文をやり取りする場合はパディングをPHPのOpenSSL関数に任せることは出来ないと判断して「OPENSSL_ZERO_PADDING」オプションを使ってOpenSSL関数にパディング処理をさせてないようにした上でパディング処理を自分で実装しています。

この記事が気に入ったらサポートをしてみませんか?