【Ruby】共通鍵暗号のベンチマークテスト


はじめに

センシティブな情報を扱う際に、平文を暗号化しDBに保存し、取り出す時に復号化することがよくあります。

業務の中で、Rubyで暗号・復号化する方法を検討し、それぞれの方法でベンチマークテストを行う機会があったので、その時の内容をまとめました。

検討した方法は、Ruby標準ライブラリOpenSSL(自前の実装)とAWS Key Management Service(KMS)の2つになります。

自前の実装は計算コストが掛かりそうで、KMSはネットワークコストが掛かりそうなので、その点がどう結果に出るかが焦点になります。

暗号化方式

共通鍵暗号

送信者と受信者は1つの鍵を秘密に共有し、暗号化と復号化に共通の鍵を使う方式。同じデータを常に同じ暗号文に置き換えると、その頻度から平文が推測されてしまうため、同じデータでも違う暗号文に置き換えられるように初期化ベクトル(または、ソルト)を設定する。今回は初期化ベクトルを使用しました。

Rubyの標準ライブラリのOpenSSL::Cipherを使用した場合はこんな感じです。

def encrypt(plaintext, key, iv)
  enc = OpenSSL::Cipher.new('AES-256-CBC')
  enc.encrypt
  enc.key = key
  enc.iv = iv
  enc.update(plaintext) + enc.final
end

def decrypt(encrypted_data, key, iv)
  dec = OpenSSL::Cipher.new('AES-256-CBC')
  dec.decrypt
  dec.key = key
  dec.iv = iv
  decrypted_data = dec.update(encrypted_data) + dec.final

  # 復号化したデータはASCII-8BITであるため、強制的にエンコーディングを修正する
  decrypted_data.force_encoding("UTF-8")
end

plaintext = "暗号化する文字列"

key = "共通鍵"
iv = "初期化ベクトル"

# データの暗号化
encrypted_data = encrypt(plaintext, key, iv)

# データの復号化
decrypt(encrypted_data, key, iv)

公開鍵暗号

公開鍵で暗号化を行い、秘密鍵で復号化を行う方式。

Rubyの標準ライブラリのOpenSSL::Cipherを使用した場合はこんな感じです。

def encrypt(plaintext, public_key)
  Base64.encode64(
    public_key.public_encrypt(
      data, 
      OpenSSL::PKey::RSA::PKCS1_OAEP_PADDING
    )
  )
end

def decrypt(encrypted_data, private_key)
  decrypted_data = private_key.private_decrypt(
    Base64.decode64(encrypted_data), 
    OpenSSL::PKey::RSA::PKCS1_OAEP_PADDING
  )

  # 復号化したデータはASCII-8BITであるため、強制的にエンコーディングを修正する
  decrypted_data.force_encoding("UTF-8")
end

plaintext = "暗号化する文字列"

public_key = OpenSSL::PKey::RSA.new(File.read(public_key_file))
private_key = OpenSSL::PKey::RSA.new(File.read(private_key_file))

# データの暗号化
encrypted_data = encrypt(plaintext, public_key)

# データの復号化
decrypt(encrypted_data, private_key)

ベンチマークの概要

今回、暗号化する平文は数百文字の長文のため、公開鍵暗号が使用出来ない(少し工夫すれば使用は可能だが、推奨されていない)ため、共通鍵暗号を使用することにした。

ベンチマークには、Benchmarkライブラリを使用
https://docs.ruby-lang.org/ja/latest/class/Benchmark.html

require 'benchmark'

result = Benchmark.realtime do
  # ここに計測する処理を記載する
end

puts "#{result}s"

比較対象

  • Ruby標準ライブラリOpenSSL
  • KMS
  • KMS(VPCエンドポイントを設定)

ベンチマークの条件

  • 1000回実行した場合の合計秒数を計測
  • 暗号化だけ、復号化だけのそれぞれを計測

暗号化のベンチマークスクリプト

Ruby標準ライブラリOpenSSL

require 'openssl'
require 'base64'
require 'benchmark'

def encrypt(plaintext, key, iv)
  enc = OpenSSL::Cipher.new('AES-256-CBC')
  enc.encrypt
  enc.key = key
  enc.iv = iv
  enc.update(comment) + enc.final
end

data = <<-EOS
  長文・・・・・
EOS

key = "共通鍵"
iv = "初期化ベクトル"

result = Benchmark.realtime do
  1000.times do
    encrypt(plaintext, key, iv)
  end
end

KMS

require 'aws-sdk-s3'
require 'base64'
require 'benchmark'

class KMSClient
  REGION = 'ap-northeast-1'
  ALIAS_NAME = 'KMSのAlias Name'

  def initialize
    @client = Aws::KMS::Client.new(
      region: REGION,
      # VPCエンドポイントを設定した場合はregionの代わりにこちらを指定する
      # endpoint: 'https://vpce-xxxxx.kms.ap-northeast-1.vpce.amazonaws.com',
      access_key_id: '',
      secret_access_key: '',
    )
    @alias = @client.list_aliases.aliases.find { |a| a.alias_name == ALIAS_NAME }
  end

  def encrypt(plaintext)
    ciphertext = @client.encrypt(
      key_id: @alias.target_key_id,
      plaintext: plaintext
    )

    Base64.encode64(ciphertext.ciphertext_blob)
  end
end

plaintext = <<-EOS
  長文・・・・・
EOS

client = KMSClient.new

result = Benchmark.realtime do
  1000.times do
    client.encrypt(plaintext)
  end
end

puts "#{result}s"

復号化のベンチマークスクリプト

Ruby標準ライブラリOpenSSL

require 'openssl'
require 'base64'
require 'benchmark'

def decrypt(encrypted_data, key, iv)
  dec = OpenSSL::Cipher.new('AES-256-CBC')
  dec.decrypt
  dec.key = key
  dec.iv = iv
  decrypted_data = dec.update(encrypted_data) + dec.final
  decrypted_data.force_encoding("UTF-8")
end

plaintext = <<-EOS
  長文・・・・・・
EOS

key = "共通鍵"
iv = "初期化ベクトル"

encrypted_data = encrypt(plaintext, key, iv)

result = Benchmark.realtime do
  1000.times do
    decrypt(encrypted_data, key, iv)
  end
end

puts "#{result}s"

KMS

require 'aws-sdk-s3'
require 'base64'
require 'benchmark'

class KMSClient
  REGION = 'ap-northeast-1'
  ALIAS_NAME = 'KMSのAlias Name'

  def initialize
    @client = Aws::KMS::Client.new(
      region: REGION,
      # VPCエンドポイントを設定した場合はregionの代わりにこちらを指定する
      # endpoint: 'https://vpce-xxxxx.kms.ap-northeast-1.vpce.amazonaws.com',
      access_key_id: '',
      secret_access_key: '',
    )
    @alias = @client.list_aliases.aliases.find { |a| a.alias_name == ALIAS_NAME }
    p @alias
  end

  def encrypt(plaintext)
    ciphertext = @client.encrypt(
      key_id: @alias.target_key_id,
      plaintext: plaintext
    )

    Base64.encode64(ciphertext.ciphertext_blob)
  end

  def decrypt(ciphertext_blob)
    @client.decrypt(ciphertext_blob: Base64.decode64(ciphertext_blob)).plaintext
  end
end

plaintext = <<-EOS
  長文・・・・・
EOS

client = KMSClient.new

encrypted_data = client.encrypt(plaintext)

result = Benchmark.realtime do
  1000.times do
    client.decrypt(encrypted_data)
  end
end

puts "#{result}s"

ベンチマーク結果

暗号化

方法 秒数
Ruby標準ライブラリOpenSSL 0.006588994991034269
KMS 8.035557514987886
KMS(VPCエンドポイント) 7.766658762935549

復号化

方法 秒数
Ruby標準ライブラリOpenSSL 0.0037274740170687437
KMS 8.964495759923011
KMS(VPCエンドポイント) 7.9086791928857565

まとめ

やはり、KMSはネットワークコストが顕著に出て処理が遅くなるようです。これは、暗号・復号化のメソッド呼び出しの度にAWSへのネットワークアクセスが発生するため、このような結果になったのだと思います。VPCエンドポイントを設定し、VPC内で接続出来るようにすれば少し改善されるものの、やはり自前の実装には勝てないようです。ただ、セキュリティ向上のために暗号化に使用する鍵を長くすると、自前の実装でも計算コストが上昇するので、この点は注意が必要そうです。