共通鍵方式(AES)を使って、異なる環境で暗号化と復号化を行う

## TL; DR

AES256での共通鍵方式を使い、暗号化をpython、復号化をphp環境で行う方法を紹介します。
結論は、

```php
$options = OPENSSL_RAW_DATA | OPENSSL_NO_PADDING;
// あるいは
$options = 3;
```

を指定して復号化しましょう、です。

## 1. やりたいこと（前提環境）

以下にしました流れが、ここで実現したい流れです。

1. ブラウザから、とある暗号化したい文字列を、python flaskサーバにPOSTする
2. 渡された文字列を、AES-256のCBCモードで暗号化する
3. 暗号化された文字列を、APIのresponseとしてBrowserで受け取る
4. php環境のApplication Serverへ、Browserから暗号化された文字列をPOSTする
5. php環境で、暗号化された文字列を復号化する

![overview.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/109411/17ae90da-b003-9a63-8995-c7e0fe2b129e.jpeg)



## 2. 発生した問題

上記で示した環境を実際に準備して、暗号化の仕様をきちんとそろえて実装してみても、**php環境で復号化ができません**でしたorz...

ここでポイントは、暗号化と復号化の処理を、**異なる実行環境で行う**点にあります。
世の中には、同じような構成で[出来ないとあきらめた方もいる](https://ikuty.com/2016/07/31/aes_encrypt_decrypt/)ようで、、、、


## 3. 問題の原因（phpの仕様ェ...）

結論から書くと、phpの仕様が原因だったのですが、まずは暗号化処理から簡単に説明していきます。

### 3.1 暗号化処理概要（python）

暗号化をpythonのPyCryptodome、復号化をphpのopenssl_decrypt()で行います。

暗号化はpythonのどんなライブラリを使ってもよいです。
ここでポイントは、AESで暗号化するために、その暗号化するplain textはblock_sizeで割り切れる文字列長である必要があります。

暗号化の処理は、例えば[このstackoverflowの記事](https://stackoverflow.com/questions/12524994/encrypt-decrypt-using-pycrypto-aes-256)のようにやれば良いです。

具体的には、**AESCipher**クラスの **encrypt** methodで以下のような処理をしている点です。

```python
raw = self._pad(raw)
```

AES.block_sizeまでpaddingすることで、任意の文字列長の暗号化文字列を、AESの仕様で暗号化できるようにしている点です。


### 3.2 復号化処理概要（php）

復号化には、openssl_decrypt関数を使用します。
その前に、そもそもopenssl_encrypt関数の、余計な(?)仕様について紹介します。

openssl_encrypt関数では、その引数にoptionsを与えることができ、[Documentにこんなことが書かれています](https://www.php.net/manual/en/function.openssl-encrypt.php)。

> options
> options is a bitwise disjunction of the flags OPENSSL_RAW_DATA and OPENSSL_ZERO_PADDING.

この**OPENSSL_ZERO_PADDING**が実は曲者です。
**Documentのどこにも書いてありません**が、実はplain textが、暗号化方式ごとに決まっている**block_sizeの長さの倍数と一致するかしないか**で挙動が異なります。

## 4. 検証結果

以下に、実際にどのような動きをするかを検証していきます。


### 4.1 検証その1: plain textが15bytesの時

まずは、15bytesの長さのplain textを、phpのopenssl_encrypt関数で暗号化するときに、何が起こっているのかを検証してみます。

```php
<?php
// 暗号化方式
$method = 'aes-256-cbc';

// 方式に応じたIV(初期化ベクトル)に必要な長さを取得
$ivLength = openssl_cipher_iv_length($method);
var_dump('ivLength: ' . $ivLength);

var_dump("## 暗号化するplain textは15 bytesとする ##");
$plaintext = 'plainplainplain';
var_dump('plaintext: ' . $plaintext);
var_dump('plaintext size: '. strlen($plaintext));

$test_key = hash('sha256', 'this is a secret key.');
$test_iv = openssl_random_pseudo_bytes($ivLength);
// 
var_dump('optionsに0を指定して暗号化する');
$encrypted = openssl_encrypt($plaintext, $method, $test_key, 0, $test_iv);
var_dump('encrypted: '. $encrypted);
var_dump('暗号化後のサイズは16byteのまま');
var_dump("encrypted size: ". strlen(base64_decode($encrypted)));

$decrypted = openssl_decrypt(base64_decode($encrypted), $method, $test_key, 0, $test_iv);
var_dump('optionsに0を指定して復号化してみると、復号化できない');
var_dump('decrypted text: '. $decrypted);
var_dump('decrypted size: '. strlen($decrypted));

$decrypted = openssl_decrypt(base64_decode($encrypted), $method, $test_key, 1, $test_iv);
var_dump('optionsに1を指定して復号化すると、復号化できてpaddingされた空白も除去された');
var_dump('decrypted text: '. $decrypted);
var_dump('decrypted size: '. strlen($decrypted));

$decrypted = openssl_decrypt(base64_decode($encrypted), $method, $test_key, 3, $test_iv);
var_dump('optionsに3を指定して復号化すると、復号化できてpaddingされた空白が残されている');
var_dump('decrypted text: '. $decrypted);
var_dump('decrypted size: '. strlen($decrypted));
```

この時の出力は、以下のようになります。

```
string(12) "ivLength: 16"
string(51) "## 暗号化するplain textは15 bytesとする ##"
string(26) "plaintext: plainplainplain"
string(18) "plaintext size: 15"
string(40) "optionsに0を指定して暗号化する"
string(35) "encrypted: CssHXt5pBqPtEyfNGza5Pw=="
string(42) "暗号化後のサイズは16byteのまま"
string(18) "encrypted size: 16"
string(73) "optionsに0を指定して復号化してみると、復号化できない"
string(16) "decrypted text: "
string(17) "decrypted size: 0"
string(104) "optionsに1を指定して復号化すると、復号化できてpaddingされた空白も除去された"
string(31) "decrypted text: plainplainplain"
string(18) "decrypted size: 15"
string(107) "optionsに3を指定して復号化すると、復号化できてpaddingされた空白が残されている"
string(32) "decrypted text: plainplainplain"
string(18) "decrypted size: 16"
```

plain textが15bytesの場合、つまり16bytesより小さいとき、**1文字分のPKCS#7 paddingが行われ**、16bytesの文字列にされてから暗号化されています。。
そのためか、optionsに0を指定して暗号化した暗号文を復号化しようとしたとき、

* 復号化時のoptionsに0を指定
    - 失敗する
* 復号化時のoptionsに1を指定
    - 成功し、元のplain textが得られる
* 復号化時のoptionsに3を指定
    - 成功するが、block_sizeまでPKCS#7 paddingされたplain textが得られる

という結果となりました。


### 4.2 検証その2: plain textが16bytesの時

plain textが16bytesの時、すなわち暗号化方式がAES256 CBCモードの時のblock sizeと一致するとどうなるでしょう。


```php
<?php
// 暗号化方式
$method = 'aes-256-cbc';

// 方式に応じたIV(初期化ベクトル)に必要な長さを取得
$ivLength = openssl_cipher_iv_length($method);
var_dump('ivLength: ' . $ivLength);

var_dump("## 暗号化するplain textは16 bytesとする ##");
$plaintext = 'plainplainplaina';
var_dump('plaintext: ' . $plaintext);
var_dump('plaintext size: '. strlen($plaintext));

$test_key = hash('sha256', 'this is a secret key.');
$test_iv = openssl_random_pseudo_bytes($ivLength);
// 
var_dump('optionsに0を指定して暗号化する');
$encrypted = openssl_encrypt($plaintext, $method, $test_key, 0, $test_iv);
var_dump('encrypted: '. $encrypted);
var_dump('なんと、暗号化するとサイズが32byteに倍増している');
var_dump("encrypted size: ". strlen(base64_decode($encrypted)));


$decrypted = openssl_decrypt(base64_decode($encrypted), $method, $test_key, 0, $test_iv);
var_dump('optionsに0を指定して復号化してみると、復号化できない');
var_dump('decrypted text: '. $decrypted);
var_dump('decrypted size: '. strlen($decrypted));

$decrypted = openssl_decrypt(base64_decode($encrypted), $method, $test_key, 1, $test_iv);
var_dump('optionsに1を指定して復号化してみると、復号化できてpaddingされた空白も除去された16bytesのplain textが得られる');
var_dump('decrypted text: '. $decrypted);
var_dump('decrypted size: '. strlen($decrypted));

$decrypted = openssl_decrypt(base64_decode($encrypted), $method, $test_key, 3, $test_iv);
var_dump('ところが、optionsに3を指定して復号化された文字列を見てみると、空白が倍の32bytesまでpaddingされている');
var_dump('decrypted text: '. $decrypted);
var_dump('decrypted size: '. strlen($decrypted));
```

この時の出力は、以下のようになります。

```
string(51) "## 暗号化するplain textは16 bytesとする ##"
string(27) "plaintext: plainplainplaina"
string(18) "plaintext size: 16"
string(40) "optionsに0を指定して暗号化する"
string(55) "encrypted: Cvz6VKiAbOto6GTasF8m+q4zEodg2uiBqcaZvdb4460="
string(69) "なんと、暗号化するとサイズが32byteに倍増している"
string(18) "encrypted size: 32"
string(73) "optionsに0を指定して復号化してみると、復号化できない"
string(16) "decrypted text: "
string(17) "decrypted size: 0"
string(145) "optionsに1を指定して復号化してみると、復号化できてpaddingされた空白も除去された16bytesのplain textが得られる"
string(32) "decrypted text: plainplainplaina"
string(18) "decrypted size: 16"
string(138) "ところが、optionsに3を指定して復号化された文字列を見てみると、空白が倍の32bytesまでpaddingされている"
string(48) "decrypted text: plainplainplaina"
string(18) "decrypted size: 32"
```

plain textが16bytesの場合、つまりblock sizeと一致するとき、**さらにもう1block size分のPKCS#7 paddingが行われ**、32bytesの文字列にされてから暗号化されています。。
そのためか、optionsに0を指定して暗号化した暗号文を復号化しようとしたとき、

* 復号化時のoptionsに0を指定
    - 失敗する
* 復号化時のoptionsに1を指定
    - 成功し、元のplain textが得られる
* 復号化時のoptionsに3を指定
    - 成功するが、もう1block_sizeまでPKCS#7 paddingされ、**2倍の長さのplain textが得られる**

という結果となりました。

要するに、phpのopenssl_encrypt()は、plain textがblock sizeと一致する時、
元の**plain textを意図せず改変してから暗号化**し、openssl_decrypt()もこれを前提としていることがわかります。

これは問題です。
cross-platformでシステムを構築しようと思ったとき、暗号化/復号化がデフォルトのままでは動かないことを意味しています。
実際、[3.1節](#31-暗号化処理概要python)で紹介したpythonで暗号化した暗号文は、phpでは素直に復号化できません。


### 4.3 検証その3: plain textが16bytesで、options = 3で暗号化した時

plain textが、暗号化方式がAES256 CBCモードの時のblock sizeと一致する16bytesの時、options = 3で暗号化するとどうなるでしょう。
要するに、この例では、phpとは異なる環境で暗号化された暗号文を復号化することを模しています。


```php
<?php
// 暗号化方式
$method = 'aes-256-cbc';

// 方式に応じたIV(初期化ベクトル)に必要な長さを取得
$ivLength = openssl_cipher_iv_length($method);
var_dump('ivLength: ' . $ivLength);

var_dump("## 暗号化するplain textは16 bytesとする ##");
$plaintext = 'plainplainplaina';
var_dump('plaintext: ' . $plaintext);
var_dump('plaintext size: '. strlen($plaintext));

$test_key = hash('sha256', 'this is a secret key.');
$test_iv = openssl_random_pseudo_bytes($ivLength);
// 
var_dump('optionsに0を指定して暗号化する');
$encrypted = openssl_encrypt($plaintext, $method, $test_key, 2, $test_iv);
var_dump('encrypted: '. $encrypted);
var_dump('暗号化後のサイズは16byteのまま');
var_dump("encrypted size: ". strlen(base64_decode($encrypted)));

$decrypted = openssl_decrypt(base64_decode($encrypted), $method, $test_key, 0, $test_iv);
var_dump('optionsに0を指定して復号化してみると、復号化できない');
var_dump('decrypted text: '. $decrypted);
var_dump('decrypted size: '. strlen($decrypted));

$decrypted = openssl_decrypt(base64_decode($encrypted), $method, $test_key, 2, $test_iv);
var_dump('optionsに1を指定して復号化してみると、復号化できない');
var_dump('decrypted text: '. $decrypted);
var_dump('decrypted size: '. strlen($decrypted));

$decrypted = openssl_decrypt(base64_decode($encrypted), $method, $test_key, 2, $test_iv);
var_dump('optionsに2を指定して復号化してみると、復号化できない');
var_dump('decrypted text: '. $decrypted);
var_dump('decrypted size: '. strlen($decrypted));

$decrypted = openssl_decrypt(base64_decode($encrypted), $method, $test_key, 3, $test_iv);
var_dump('optionsに3を指定して復号化された文字列を見てみると、意図どおりの復号化ができている');
var_dump('decrypted text: '. $decrypted);
var_dump('decrypted size: '. strlen($decrypted));

```

この時の出力は、以下のようになります。

```
string(51) "## 暗号化するplain textは16 bytesとする ##"
string(27) "plaintext: plainplainplaina"
string(18) "plaintext size: 16"
string(40) "optionsに0を指定して暗号化する"
string(35) "encrypted: ZURbie5cEC4ltBXToPe00A=="
string(42) "暗号化後のサイズは16byteのまま"
string(18) "encrypted size: 16"
string(73) "optionsに0を指定して復号化してみると、復号化できない"
string(16) "decrypted text: "
string(17) "decrypted size: 0"
string(73) "optionsに1を指定して復号化してみると、復号化できない"
string(16) "decrypted text: "
string(17) "decrypted size: 0"
string(73) "optionsに2を指定して復号化してみると、復号化できない"
string(16) "decrypted text: "
string(17) "decrypted size: 0"
string(118) "optionsに3を指定して復号化された文字列を見てみると、意図どおりの復号化ができている"
string(32) "decrypted text: plainplainplaina"
string(18) "decrypted size: 16"
```


optionsに3を指定したもの以外、復号化に失敗しました。
source codeまで追っていませんが、openssl_decrypt()が32bytesの長さの暗号文を期待した復号化処理をしようとしたが、復号化に失敗しNULLが返ってきたということのようです。
したがって、初めからpaddingするような余計な処理を行わないようなoptionsである、3を指定した時だけ復号化に成功するのでしょう。


実際、pythonで暗号化した暗号文をphpで復号化するためには、optionsに3を指定した時だけ成功しました。
これは、[3.1節](#31-暗号化処理概要python)で消化したように、python側でplain textをblock sizeまでpaddingする処理を行っているためです。
暗号文の長さがblock sizeと一致するため、php側で意図して何もさせない（=optionsに3を指定する）ようにしないと、復号化ができないということです。


## 5. 問題の解決方法

長々と紹介しました。
異なるplatformで暗号化された暗号文をphpで復号化するためには、以下のようにする必要があります。

```php
$options = OPENSSL_RAW_DATA | OPENSSL_NO_PADDING;
// あるいは
$options = 3;
```

このoptionsについては、phpのopenssl_encryptの[マニュアルのかなり下のほう](https://www.php.net/manual/ja/function.openssl-encrypt.php#126267)に書かれていました。
decrypt側じゃなくて、encrypt側に書いてあるんですね。。。。。