OpenSSL v3環境下のRubyで現在は非推奨な古い暗号化方式を扱う方法

こんにちは。
株式会社ドワンゴ BW開発本部 サービス開発部 Webサービス開発セクションのフサギコ(髙﨑)です。
Ruby on Railsによるバックエンドの実装運用とAWSによるサービスインフラの設計構築を中心とした、いわゆるバックエンド方面のテックリードとしてニコニコ漫画を開発しています。

2025年4月1日をもって株式会社ブックウォーカーが株式会社ドワンゴに吸収合併となりましたので、それに伴い僕も株式会社ドワンゴ所属となりました。
かつて2019年7月1日付けで株式会社トリスタへの出向になるまではドワンゴの所属でしたので、実に5年半ぶりの出戻りです。しかも会社都合。こんな面白いこともあるんだなぁと思いつつ。

このブログもしばらくはBW事業本部ならびにBW開発本部を中心としたメンバーの技術ブログとして運用されると思いますので、引き続きよろしくお願いします。

本記事では、OpenSSL バージョン3がインストールされている環境下のRubyにおいて、現在は非推奨な古い暗号化方式を扱う方法についてお話します。

気が早い人のために先に結論

OpenSSL::Provider.load("legacy") を実行したあとで OpenSSL::Cipher クラスをnewするとOpenSSL バージョン3では非推奨とされている古い暗号化方式でも初期化できるようになります。

Rubyで古い暗号化方式を扱う必要が生じた

ニコニコ漫画では、13年弱前のサービス開始より現在まで稼働し続けている現行PHPアプリケーションのビジネスロジックを、 Ruby on RailsのAPIモードとして構築した新バックエンドに向けて仕様整理しつつ移譲する、いわゆる式年遷宮を進めています。

その一環としてあるテーブルをRailsのモデルとして書き起こそうとした際、一部のカラムがBlowfish暗号によって暗号化されていました。
そのため、現行PHPとの互換性をとりながら新バックエンドへ移行するためにBlowfish暗号を暗号化および平文化できる必要が生じました。

Rubyで暗号を扱う方法…とエラー

Railsにおけるカラム暗号化といえばActiveRecord::Encryptionがありますが、 今回の場合は利用箇所がその1モデルだけであったことから、ActiveRecord::Encryption::Encryptorを実装するなどはいったんせず、単に暗号化と平文化ができるようなmoduleメソッドを持ったmoduleとして実装することにしました。

Rubyではopenssl gemを使えば暗号化および平文化が行えます。 Blowfish暗号に対応するOpenSSL::Cipher::BFクラスがあるので、下記のようにnewすると…

irb(main):001> require 'openssl'
=> true
irb(main):002> OpenSSL::Cipher::BF.new('cbc')
/home/fusagiko/.rbenv/versions/3.3.5/lib/ruby/3.3.0/openssl/cipher.rb:21:in `initialize': unsupported (Global default library context, Algorithm (BF-CBC : 11), Properties ()) (OpenSSL::Cipher::CipherError)
        from /home/fusagiko/.rbenv/versions/3.3.5/lib/ruby/3.3.0/openssl/cipher.rb:21:in `block (3 levels) in <class:Cipher>'
        from (irb):2:in `new'
        from (irb):2:in `<main>'
        from <internal:kernel>:187:in `loop'
        from /home/fusagiko/.rbenv/versions/3.3.5/lib/ruby/gems/3.3.0/gems/irb-1.14.3/exe/irb:9:in `<top (required)>'
        from /home/fusagiko/.rbenv/versions/3.3.5/bin/irb:25:in `load'
        from /home/fusagiko/.rbenv/versions/3.3.5/bin/irb:25:in `<main>'

エラーになってしまいました。

エラーについて調べる

とりあえず、エラーの本文っぽい unsupported (Global default library context, Algorithm (BF-CBC : 11), Properties ()) をgoogleの検索欄にコピー&ペーストして検索してみたところ、 そのものズバリな結果は出ませんでしたが、OpenSSLをv3系にアップデートしたときに発生する、といったようなPHPやOpenVPNなどにおける事例が発見できました。

確かに、OpenSSLがv1系からv3系へメジャーアップデートするにあたって、暗号化強度の不足により過去の暗号アルゴリズムがいくつかdeprecatedになった影響で色々あった、というような話を聞いたことがありました。

openssl versionコマンドを実行すると、確かに下記の通りOpenSSL v3系でした。

fusagiko@fuyuko24:~$ openssl version
OpenSSL 3.0.13 30 Jan 2024 (Library: OpenSSL 3.0.13 30 Jan 2024)
fusagiko@fuyuko24:~$

また、当時本番環境で使用していたdockerイメージのベースはdocker公式のruby:3.3.5-slimだったため、実態としてはdebianのbookwormであり、そちらもOpenSSL v3系でした

更に調べると、opensslコマンドでは -provider legacy というオプションを付けると実行可能になるようでした。

fusagiko@fuyuko24:~$ echo -n "This is test" | openssl enc -bf-cbc -K "0123456789ABCDEF0123456789ABCDEF" -iv "FEDCBA9876543210" -base64 -A
Error setting cipher BF-CBC
40177B887A730000:error:0308010C:digital envelope routines:inner_evp_generic_fetch:unsupported:../crypto/evp/evp_fetch.c:386:Global default library context, Algorithm (BF-CBC : 11), Properties ()
fusagiko@fuyuko24:~$ echo -n "This is test" | openssl enc -bf-cbc -K "0123456789ABCDEF0123456789ABCDEF" -iv "FEDCBA9876543210" -base64 -A -provider legacy
BQkcxHzhQpiVKwijeM5ZAg==fusagiko@fuyuko24:~$
fusagiko@fuyuko24:~$ echo -n BQkcxHzhQpiVKwijeM5ZAg== | openssl enc -d -bf-cbc -K "0123456789ABCDEF0123456789ABCDEF" -iv "FEDCBA9876543210" -base64 -A -provider legacy
This is test
fusagiko@fuyuko24:~$

openssl gemでOpenSSLのlegacyプロバイダを使用する

したがって、このオプションをRubyでも指定できれば平文化できそう…だと思いnewするときにキーワード引数でオプションを指定出来たりしないかと試してみたのですが…

irb(main):001> require 'openssl'
=> true
irb(main):002> OpenSSL::Cipher::BF.new('cbc', provider: "legacy")
/home/fusagiko/.rbenv/versions/3.3.4/lib/ruby/3.3.0/openssl/cipher.rb:21:in `initialize': unsupported cipher algorithm (bf-cbc-{:provider=>"legacy"}) (RuntimeError)
        from /home/fusagiko/.rbenv/versions/3.3.4/lib/ruby/3.3.0/openssl/cipher.rb:21:in `block (3 levels) in <class:Cipher>'
        from (irb):2:in `new'
        from (irb):2:in `<main>'
        from <internal:kernel>:187:in `loop'
        from /home/fusagiko/.rbenv/versions/3.3.4/lib/ruby/gems/3.3.0/gems/irb-1.15.1/exe/irb:9:in `<top (required)>'
        from /home/fusagiko/.rbenv/versions/3.3.4/bin/irb:25:in `load'
        from /home/fusagiko/.rbenv/versions/3.3.4/bin/irb:25:in `<main>'
irb(main):003>

そううまくはいきませんでした*1

最終的にC拡張のコードまで読みに行きましたが使えそうなオプションを発見することはできず、ruby-jp Slackにて

OpenSSL gemについて質問があります。
過去資産との互換性の都合でbf-cbcな暗号をdecryptする必要があるのですが、現在使用しているopensslのバージョンが3系なせいかOpenSSL::Cipher::BF.new('cbc')でunsupportedエラーがraiseされてしまいます。
opensslコマンドで試したところ、opensslコマンドでは-provider legacy オプションを指定すれば動作するようなのですが、OpenSSL gemでこれに準ずるオプションを指定する方法はありますでしょうか

このように質問したところ、まもなく

OpenSSL::Provider.load("legacy")
ですかね
https://github.com/ruby/openssl/pull/635

と教えていただけました!

確かに、下記のように OpenSSL::Provider.load("legacy") を実行したあとでOpenSSL::Cipher::BFクラスをnewするとエラーが発生しませんでした。

irb(main):001> require 'openssl'
=> true
irb(main):002> OpenSSL::Cipher::BF.new('cbc')
/home/fusagiko/.rbenv/versions/3.3.5/lib/ruby/3.3.0/openssl/cipher.rb:21:in `initialize': unsupported (Global default library context, Algorithm (BF-CBC : 11), Properties ()) (OpenSSL::Cipher::CipherError)
        from /home/fusagiko/.rbenv/versions/3.3.5/lib/ruby/3.3.0/openssl/cipher.rb:21:in `block (3 levels) in <class:Cipher>'
        from (irb):2:in `new'
        from (irb):2:in `<main>'
        from <internal:kernel>:187:in `loop'
        from /home/fusagiko/.rbenv/versions/3.3.5/lib/ruby/gems/3.3.0/gems/irb-1.14.3/exe/irb:9:in `<top (required)>'
        from /home/fusagiko/.rbenv/versions/3.3.5/bin/irb:25:in `load'
        from /home/fusagiko/.rbenv/versions/3.3.5/bin/irb:25:in `<main>'
irb(main):003> OpenSSL::Provider.load("legacy")
=> #<OpenSSL::Provider name="legacy">
irb(main):004> OpenSSL::Cipher::BF.new('cbc')
=> #<OpenSSL::Cipher::BF:0x00007bcac88ba740>
irb(main):005>

RailsでOpenSSLのlegacyプロバイダを使ってbf-cbcの暗号化/平文化処理を実装する

というわけで、 config/initializers/openssl.rb を作成し、その中で OpenSSL::Provider.load("legacy") を実行するようにしました。

そのうえでmoduleを作成し、その特異メソッドとしてdecryptおよびencryptメソッドを実装しました。 暗号化と平文化それぞれの処理をPHPの実装と比較すると、下記のようになりました。

暗号化

<?php
public function encrypt($value, $salt = '')
{
    $blockSize = 8;
    if ($tailSize = strlen($value) % $blockSize) {
        $value .= str_repeat("\0", $blockSize - $tailSize);
    }

    $iv  = $this->generateIv($salt);
    $key = $this->getKey($salt);
    return base64_encode(
        openssl_encrypt($value, 'bf-cbc', $key, OPENSSL_RAW_DATA | OPENSSL_ZERO_PADDING, $iv)
    );
}
module PhpCrypto
  def self.setup_cipher(salt)
    cipher = OpenSSL::Cipher.new('bf-cbc')
    cipher.padding = 0
    cipher.key_len = 56

    keys = generate_keys(salt)
    cipher.key = keys[:key]
    cipher.iv = keys[:iv]
    cipher
  end

  def self.encrypt(value, salt)
    cipher = setup_cipher(salt)
    cipher.encrypt

    block_size = cipher.block_size
    if((tail_size = value.bytesize % block_size) != 0)
      value += "\0" * (block_size - tail_size)
    end

    encrypted = cipher.update(value)
    Base64.strict_encode64(encrypted).chomp
  end
end

平文化

<?php
public function decrypt($value, $salt = '')
{
    $iv  = $this->generateIv($salt);
    $key = $this->getKey($salt);
    return rtrim(
        openssl_decrypt(base64_decode($value), 'bf-cbc', $key, OPENSSL_RAW_DATA | OPENSSL_ZERO_PADDING, $iv),
        "\0"
    );
}
module PhpCrypto
  def self.decrypt(value, salt)
    cipher = setup_cipher(salt)
    cipher.decrypt

    encrypted = Base64.strict_decode64(value)
    decrypted = cipher.update(encrypted)
    decrypted.rstrip!
    decrypted
  end
end

テストと実装手順

テストは、主に下記のように記述しました。

describe PhpCrypto do
  describe 'encrypt' do
    it 'bf-cbcを使って暗号化した文字列を返す' do
      expect(described_class.encrypt(
               'aaa',
               'bbb'
             )).to eq('xibWK4p0kFQ=')

      expect(described.encrypt(
               '1234567890',
               'ABCDEFGHIJKLMN'
             )).to eq('MGCMtxZhUc+UPpxDhsOBkQ==')
    end
  end

  describe 'decrypt' do
    it 'bf-cbcを使って平文化した文字列を返す' do
      expect(described.decrypt(
               'xibWK4p0kFQ=',
               'bbb'
             )).to eq('aaa')

      expect(described.decrypt(
               'MGCMtxZhUc+UPpxDhsOBkQ==',
               'ABCDEFGHIJKLMN'
             )).to eq('1234567890')
    end
  end
end

実装手順について実際は、

  1. 本番環境の現行PHPアプリケーションを使って暗号文と平文のペアを複数出してもらった
  2. 出してもらった暗号文と平文のペアをテストに書く
  3. 本番環境と同じ暗号鍵、初期化ベクトルを環境変数から読み込ませながら、テストがpassするように実装を行う
  4. 暗号鍵と初期化ベクトルを開発用のべた書きに差し替え
  5. 暗号鍵と初期化ベクトルが変わったので当然テストがfailするが、そのactualの値をexpectに逆採用する

という手順を踏みました。

ハマったところ

PHPのbase64_encodeに対応するのはRubyではBase64.strict_encode64

現行PHPのencrypt関数は出力された暗号文をbase64でエンコードしてDBのstringカラムに格納できる文字列にしていますが、 これをメソッド名に素直にRubyのBase64モジュールのencode64モジュールメソッドを使って実装して本番投入したところ、一部の長いデータにおいて現行PHPと異なる結果が出力されてしまいました。

というのも、RubyのBase64モジュールのencode64モジュールメソッドはドキュメントにも記述がありますが、対応するRFCが2045であり、エンコード後の60文字ごとに改行が挿入されます。 実装段階ではテストと実装手順の項でお話したような短い平文でしかテストしていなかったため、この挙動の差異に気付けませんでした。

このバグは使うメソッドをBase64モジュールのstrict_encode64に単純に差し替えて解決しました。

まとめ

本記事では、OpenSSL バージョン3がインストールされている環境下のRubyにおいて、現在は非推奨な古い暗号化方式を扱う方法についてお話ししました。

本記事の内容は記事中でもお話ししましたがruby-jp Slackにて質問した内容がベースになっています。 当時お答えくださった皆さま、ありがとうございました。

さて、ここからはお決まりのような流れですが、私たちBW開発本部では物理・電子・Web連載問わず漫画や本が好きなWebアプリケーションエンジニアを募集しています。 吸収合併に伴い中途・新卒採用の窓口が統合途中のため少し複雑になっていますが、ご興味がありましたら下記の案内記事をご覧のうえご連絡ください。
カジュアル面談からでもぜひ、お待ちしております。

note.com

*1:っていうか各引数を単純にto_sしてハイフンで繋いで渡してるんだこれ…と見たとき思いました