MySQLのAES_ENCRYPT／AES_DECRYPT互換の方式でActiveRecordの属性を透過的に暗号化／復号する

Ruby on Rails (DBはMySQL) で開発をしている某案件で

> 運用の都合上、アプリ外から SQL でデータベースの内容を直接参照できる必要があるので、センシティブなデータは AES_ENCRYPT 関数で暗号化して、アプリ以外からも復号できるようにすること

という要件がありました。

単に暗号化すればいいだけなら attr_encrypted gem などを使って透過的に Ruby 側で暗号化／復号すれば楽に実装できますが、いちいち MySQL 側で AES_ENCRYPT／AES_DECRYPT させるとなると、かなり実装が面倒です。

そこで、Ruby 側で MySQL の AES_ENCRYPT／AES_DECRYPT と同一のアルゴリズムで透過的に暗号化／復号する方法を考えてみました。


# 結論

attr_encrypted gem を使い、キーとパラメータを下記のように設定すれば OK です。

```ruby:some_model.rb
class SomeModel < ActiveRecord::Base
  attr_encrypted :email,
    :algorithm => "aes-128-ecb",
    :iv => "",
    :key => :generate_key,
    :encode => false

  def generate_key()
    key = "KEY_STR"
    final_key = "\0" * 16
    key.length.times do |i|
      final_key[i % 16] = (final_key[i % 16].ord ^ key[i].ord).chr
    end
    return final_key
  end
end
```

ちなみに動作を確認した環境は

* Ruby 1.9.3 p374
* Rails 3.2.13
* attr_encrypted 1.2.1
* MySQL 5.1.68


# 詳細

## attr_encrypted で透過的に暗号化／復号する

[attr_encrypted](https://github.com/shuber/attr_encrypted) は、透過的に暗号化／復号するための gem です。`gem install attr_encrypted` でインストールできます。

Rails で ActiveRecord を使っている場合、

```ruby:20130421999999_create_some_models.rb
class CreateSomeModels < ActiveRecord::Migration
  def change
    create_table :some_models do |t|
      t.binary :encrypted_email
      t.timestamps
    end
  end
end
```

というテーブルに対して、

```ruby:some_model.rb
class SomeModel < ActiveRecord::Base
  attr_encrypted :email
end
```

というモデルを作ると、

```ruby
model = SomeModel.new
model.email = "foo@bar.com"
model.save!
```

と書けば文字列 "foo@bar.com" が暗号化されて encrypted_email カラムに保存されます。デフォルトではモデルの属性に対応するカラムには `encrypted_` というプレフィックスを付けなければなりませんが、オプションでプレフィックスやサフィックスを調整することが可能です。

この gem をベースに、パラメータなどを調整して AES_ENCRYPT／AES_DECRYPT 互換の暗号化／復号を実現します。


## 暗号化アルゴリズム、初期化ベクトルを変更する

attr_encrypted (というか内部で暗号化／復号に使ってる encryptor gem) はデフォルトで aes-256-cbc アルゴリズムを使うようになっていますが、MySQL 5.1 リファレンスの [12.13. Encryption and Compression Functions](http://dev.mysql.com/doc/refman/5.1/en/encryption-functions.html) によると、MySQL 5.1の実装では

> Block Length: 128bit
> Block Mode: ECB
> Data Padding: Padded by bytes which Asc() equal for number of padded bytes (done automagically)
> Key Padding: 0x00 padded to multiple of 16 bytes
> IV: None

ということなので、暗号化アルゴリズムを aes-128-ecb に変更します。

また、初期化ベクトルを明示的に指定しないと、内部で OpenSSL::Cipher.pkcs5_keyivgen が使われてしまう (このメソッドは内部で初期化ベクトルを生成する) ので、明示的に空の初期化ベクトルを指定する必要があります。

アルゴリズムは attr_encrypted メソッドの :algorithm、初期化ベクトルは :iv で変更できます。

```ruby:some_model.rb
class SomeModel < ActiveRecord::Base
  attr_encrypted :email,
    :algorithm => "aes-128-ecb",
    :iv => ""
end
```


## キーを変換する

MySQL は AES_ENCRYPT／AES_DECRYPT に指定されたキーを変換した上で暗号化／復号に使用しています。

どんな実装になっているかというと

```c:my_aes.c
static int my_aes_create_key(KEYINSTANCE *aes_key,
        enum encrypt_dir direction,
        const char *key, int key_length)
{
  uint8 rkey[AES_KEY_LENGTH/8];
  uint8 *rkey_end=rkey+AES_KEY_LENGTH/8;
  uint8 *ptr;
  const char *sptr;
  const char *key_end=key+key_length;

  bzero((char*) rkey,AES_KEY_LENGTH/8);

  for (ptr= rkey, sptr= key; sptr < key_end; ptr++,sptr++)
  {
    if (ptr == rkey_end)
      ptr= rkey;  /*  Just loop over tmp_key until we used all key */
    *ptr^= (uint8) *sptr;
  }
  ...
}
```

AES_KEY_LENGTH は 128 と定義されているので、キーの変換は次のような処理になっています。

+ 0 で初期化された 16 バイトの配列 rkey を作る。
+ 指定されたキー key と rkey の XOR をとって rkey に格納していく。key と rkey の 1 バイト目の XOR を rkey の 1 バイト目に格納、key と rkey の 2 バイト目の XOR を rkey の 2 バイト目に格納... という感じ。
+ カウンタが 16 の倍数を超えたら、rkey の方のカウンタを 1 に戻す。key の 17 バイト目 と rkey の 1 バイト目の XOR を rkey の 1 バイト目に格納、key の 18 バイト目と rkey の 2 バイト目の XOR を rkey の 2 バイト目に格納... という感じ。

これを Ruby のコードに起こしたのが、下記の generate_key メソッドです。

```ruby
def generate_key()
  key = "KEY_STR"
  final_key = "\0" * 16
  key.length.times do |i|
    final_key[i % 16] = (final_key[i % 16].ord ^ key[i].ord).chr
  end
  return final_key
end
```

attr_encrypted では、:key でキー生成メソッドを指定することができるので、some_model.rb は次のようになります。

```ruby:some_model.rb
class SomeModel < ActiveRecord::Base
  attr_encrypted :email,
    :algorithm => "aes-128-ecb",
    :iv => "",
    :key => :generate_key

  def generate_key()
    key = "KEY_STR"
    final_key = "\0" * 16
    key.length.times do |i|
      final_key[i % 16] = (final_key[i % 16].ord ^ key[i].ord).chr
    end
    return final_key
  end
end
```


# エンコードを抑制する

ActiveRecord で attr_encrypted を使う場合、デフォルトでは暗号化した結果を BASE64 エンコードしたうえで格納するようになっています。AES_DECRYPT するだけで復号できるようにしたいので、:encode => false を指定してエンコード処理を抑制します。

以上で、AES_ENCRYPT／AES_DECRYPT 互換の方式で透過的に暗号化／復号を行えるモデルの完成です。

```ruby:some_model.rb
class SomeModel < ActiveRecord::Base
  attr_encrypted :email,
    :algorithm => "aes-128-ecb",
    :iv => "",
    :key => :generate_key,
    :encode => false

  def generate_key()
    key = "KEY_STR"
    final_key = "\0" * 16
    key.length.times do |i|
      final_key[i % 16] = (final_key[i % 16].ord ^ key[i].ord).chr
    end
    return final_key
  end
end
```