RailsでDBの値を暗号化する

DB保存時に個人情報的なやつは暗号化しておきたいという要望はよくあるかと思います。
今回はattr_encrypted というgemを利用してみました。
自前で実装するなら ActiveSupport::MessageEncryptor が利用できるようですのでこちらも少し試してみました。

まずはActiveSupport::MessageEncryptor

以下のような module を作成し、(cipherについては後述)
encrypt_secure_key は secrets.yml に定義。

module Encryptor
  CIPHER = "aes-256-cbc"
  def encrypt(password)
    secure = Rails.application.secrets.encrypt_secure_key
    crypt = ActiveSupport::MessageEncryptor.new(secure, CIPHER)
    crypt.encrypt_and_sign(password)
  end

  def decrypt(password)
    secure = Rails.application.secrets.encrypt_secure_key
    crypt = ActiveSupport::MessageEncryptor.new(secure, CIPHER)
    crypt.decrypt_and_verify(password)
  end
end

以下のようにmodel側でincludeすれば暗号化できます。復号化もdecrypt呼ぶだけ。

class User < ApplicationRecord
  include Encryptor
  before_save :encrypt_collumns

  def encrypt_collumns
    self.address = encrypt(self.address)
  end
end

簡単に実装はできましたが、decryptするのが少し面倒。self.address を decryptした項目を返すようにすれば良さそうですが
項目増えるたびにメソッド書くのもなぁという気がします。
 

次は attr_encrypted

使い方

例えば ssn という項目を暗号化させたい場合、encrypted_ssnencrypted_ssn_iv を用意します。

  create_table :users do |t|
    t.string :name
    t.string :encrypted_ssn
    t.string :encrypted_ssn_iv
    t.timestamps
  end

モデル側で以下のようにすればok。簡単に利用できて良いです。

class User
  attr_encrypted :ssn, key: 'This is a key that is 256 bits!!'
end

補足

  • 項目のPrefixencrypted_は変更可能
  • iv について
    そもそもivは、暗号化の初期ベクトルのことです。暗号化時には指定されたブロック単位で暗号化して行きますが、前のブロックの暗号化データを排他的論理和で論理演算するという仕組みですが、この初期の暗号化時には前のブロックデータがないので、代わりに与えるのがivとのこと。
  • modeについて
    mode というオプションがあり、現在デフォルトはper_attribute_iv というivが必要なものが設定されています。
    per_attribute_iv_and_saltsingle_iv_and_salt というものも現在は使用できますが、次回メジャーバージョンアップ時にはなくなる予定とのこと。

 
以下余談です。

Cipher Suites について

ActiveSupport::MessageEncryptor のデフォルトは aes-256-cbc でした。
attr_encrypted は aes-256-gcm です。
ほとんどの人はデフォルトのものを利用するでしょうし、このような用途では通常ivなどの値がhttpのデータとして通信されることはないので、これで良い気がしますが、少し気になったので調べてみます。
 
使用可能なcipherは以下で確認できます。

require 'openssl'
puts OpenSSL::Cipher.ciphers

aes-256だけでも沢山あり、どれ選んでいいかさっぱりわかりません。

aes-256-cbc
aes-256-cbc-hmac-sha1
aes-256-cbc-hmac-sha256
aes-256-ccm
aes-256-cfb
aes-256-cfb1
aes-256-cfb8
aes-256-ctr
aes-256-ecb
aes-256-gcm
aes-256-ofb
aes-256-xts

Stack Overflow

Stack Overflow に一部解説がありました。
How to choose an AES encryption mode (CBC ECB CTR OCB CFB)? - Stack Overflow

  • ECB should not be used if encrypting more than one block of data with the same key.
    同一キーで複数ブロック暗号化するなら使用するな
  • CBC, OFB and CFB are similar, however OFB/CFB is better because you only need encryption and not decryption, which can save code space.
    OFB/CFB は復号化を必要としないなら省スペース。
  • CTR is used if you want good parallelization (ie. speed), instead of CBC/OFB/CFB.
    CTRは並列化が必要なら利用される。
  • XTS mode is the most common if you are encoding a random accessible data (like a hard disk or RAM).
    XTSはランダムアクセス時に最も利用される。
  • OCB is by far the best mode, as it allows encryption and authentication in a single pass. However there are patents on it in USA.
    OCBは効率よく一番いいけど、特許の問題がある。

他にも、uniqueなivが使えないならOCBを使えとあります。
コメントには、GCMはOCBにとても似ており、特許の問題がないので一番良い選択肢だとあります。(ただし実装が複雑)
 
GCMで良い気がしてきました。

一応ivの生成を確認してみます。

# attr_encrypted-3.0.3/lib/attr_encrypted.rb
      def generate_iv(algorithm)
        algo = OpenSSL::Cipher.new(algorithm)
        algo.encrypt
        algo.random_iv
      end

random_iv は以下。

# ruby 2.4.0p0 (2016-12-24 revision 57164) [x86_64-darwin16]
def random_iv
  str = OpenSSL::Random.random_bytes(self.iv_len)
  self.iv = str
end

強度は iv_len で決まりますが、値は12でした。
これが強いのか弱いのか、、、よくわかりません。

("\x00".."\xff").to_a.size
=> 58

なので、58の12乗ということなのかしら。まぁまぁ大丈夫そう。

Not so clever(CodeEval)

Bubble Sort のような(よりも効率の悪い)ソートアルゴリズムを実装する問題。

CHALLENGE DESCRIPTION:

Imagine that you have to arrange items in a certain order: pencils from black to white in a color palette, photographs by the date taken, banknotes from the highest to the lowest, etc. To do this, you definitely don’t need to use the Stupid sort algorithm.
 
After each action, you need to come back to the beginning and start all over again. Not so clever, is it? But, you need to know about this algorithm, that’s why it is used in this challenge.

INPUT SAMPLE:

The first argument is a path to a file. Each line includes a test case which contains numbers that you need to sort using the Stupid sort algorithm. There is also a number of iterations for an algorithm to carry out. The numbers themselves and the number of iterations are separated by a pipeline ‘|’.

4 3 2 1 | 1
5 4 3 2 1 | 2

OUTPUT SAMPLE:

Print sorted numbers after they pass the required number of iterations. One iteration of this sort is a pass to the moment of making changes. Once changing the order of the digits, passing starts from the very beginning. Hence, this is another iteration.

3 4 2 1
4 3 5 2 1

CONSTRAINTS:

  1. The number of iterations can be from 1 to 8.
  2. One iteration of this sort is a pass to the moment of making changes.
  3. The number of test cases is 40.

My Code

#!/usr/bin/env ruby -w

def stupid_sort(numbers, iteration_number)
  iteration_number.times { sort_once!(numbers) }
  numbers
end

def sort_once!(numbers)
  numbers.each_with_index do |n, i|
    x = numbers[i]
    y = numbers[i + 1]
    if x > y
      numbers[i] = y
      numbers[i + 1] = x
      return numbers
    end
  end
end

ARGF.each_line do |line|
  _numbers, _iteration_number = line.chomp.split(" | ")
  numbers = _numbers.split.map(&:to_i)
  iteration_number = _iteration_number.to_i
  puts stupid_sort(numbers, iteration_number).join(" ")
end

Football(CodeEval)

「チーム毎のメンバーが所属する国のリスト」を「国別のチーム一覧」に変換する問題。
日本語にすると一見複雑で、途中で何やってるかわからなくなる。
nested な配列生成が少し汚い。injectにしてもあまり変わらない。
メソッド分割をすれば良いのかもしれないが、名前が複雑になりそうなので、とりあえず最初に書いたまま。
 

CHALLENGE DESCRIPTION:

People around the world watch football matches and root for different football teams. Some people are fans of Real Madrid, some like Barcelona, some pull for Atletico Madrid, while others do not miss a single match with FC Bayern Munich. The teams would like to know people in which countries cheer for them. So, let’s help them!

INPUT SAMPLE:

The first argument is a path to a file. Each row includes a test case with lists of countries. Lists are separated by pipelines ‘|’. Each list includes football teams that people in these countries root for.

1 2 3 4 | 3 1 | 4 1
19 11 | 19 21 23 | 31 39 29

OUTPUT SAMPLE:

For each football team, print a list of countries where people root for them. Separate each team by a semicolon ‘;’ and a space. All output should be sorted.

1:1,2,3; 2:1; 3:1,2; 4:1,3;
11:1; 19:1,2; 21:2; 23:2; 29:3; 31:3; 39:3;

CONSTRAINTS:

  1. The number of countries lists can be from 3 to 20.
  2. Each list contains a different number of football teams: from 1 to 7.
  3. The number of test cases is 40.

My Code

#!/usr/bin/env ruby -w

def teams_by_country(teams)
  countries = teams.flatten.sort.uniq
  results = []
  countries.each do |country|
    result = []
    teams.each_with_index do |team, i|
      result << "#{i + 1}" if team.include?(country)
    end
    results << "#{country}:#{result.join(",")};"
  end
  results.join(" ")
end

ARGF.each_line do |line|
  teams = line.chomp.split(" | ").map { |t| t.split.map(&:to_i) }
  puts teams_by_country(teams)
end