mixi engineer blog

*** 引っ越しました。最新の情報はこちら → https://medium.com/mixi-developers *** ミクシィ・グループで、実際に開発に携わっているエンジニア達が執筆している公式ブログです。様々なサービスの開発や運用を行っていく際に得た技術情報から採用情報まで、有益な情報を幅広く取り扱っています。

OpenSSLの暗号文をJava/Perl/Rubyで開く

秘密鍵やプライベートな情報などを秘匿するためにパスワードでデータを暗号化・復号したい場合があります。このとき、暗号化と復号するアプリケーションが同じであれば簡単ですが、例えばCで暗号化してJava、Perl、Rubyで復号するといった風に異なるプラットフォームで暗号データをやりとりする場合には、いくつか気 をつけなければいけないポイントがあります。

OpenSSLによる暗号化

OpenSSLはWebサーバのSSL/TLSサポートに利用されますが、その他にも付属しているopensslコマンドから基本的な暗号アルゴリズムを利用できます。次のような簡単なコマンドで、パスワードを使ってデータを暗号化したり復号したりすることができます:
$ echo 'Hello World!' | openssl enc -e -aes-128-cbc  > cipher.txt
enter aes-128-cbc encryption password: ********
この例では、文字列 "Hello World!"を暗号化してファイル cipher.txt に出力しています。また、暗号化に際して次のパラメータを使用しました:
  • 暗号アルゴリズムはAES
  • 秘密鍵の長さは128bit
  • 暗号利用モードはCBCモード
  • 秘密鍵はパスワードをもとにopensslコマンドが自動生成
ファイルchiper.txtを復号してメッセージを取り出す場合は:
$ openssl enc -d -aes-128-cbc < cipher.txt
enter aes-128-cbc decryption password:********
Hello World!
あたりまえですが、暗号化したときに使ったパスワードとパラメータは、復号するときにも同じものを使用します。 ちなみに、この場合のopensslコマンドは次のような書式で暗号文を出力します。
'Salted__' + Salt(8 byte) + CipherText(16 x n byte)
ヘッダとして文字列 "Salted__"、続いてランダムに生成された8バイトのsalt、最後に暗号文本体が続きます。

別のプラットフォームで復号する

それでは、暗号化したデータをPerl, Ruby, Javaで復号する例を順番に見てみます。

Perlで復号する

Perlにはopensslコマンドと暗号文をやりとりできるCrypt::CBCモジュールがあります。こんな感じ:
#!/usr/bin/perl

use strict;
use Crypt::CBC 2.13;

my $pbe = Crypt::CBC->new(
     -key     => 'my secret password',
     -cipher  => 'Crypt::Rijndael',
     -keysize => 128/8,
#    -salt    => 1,          # use random salt
#    -header  => 'salt',     # use OpenSSL-compatible salted header
#    -padding => 'standard', # use PKCS#5 PBES1|2 padding
);
my $cipher_text = scalar <>;
my $plain_text = $pbe->decrypt($cipher_text);
print $plain_text;
Crypt::CBCは-keyで与えられたパスワードを、暗号アルゴリズムが要求する形式の秘密鍵に変換して使用します。

Rubyで復号する

RubyにはOpenSSLのcryptoライブラリをwrapしたOpenSSL::Cipherクラスがあります。PerlのCrypt::CBCと異なり、明示的にpkcs5_keyivgenメソッドでパスワードを秘密鍵に変換して使用します。
#!/usr/bin/ruby

require 'openssl'
include OpenSSL::Cipher

# 'Salted__' | SALT(0..7) | CIPHER-TEXT
@cipher_text = gets.unpack("a8a8a*")
pbes = Cipher.new("aes-128-cbc")
pbes.pkcs5_keyivgen(
     "my secret password",
     @cipher_text[1],                 # salt
     1                                # iteration count
#    ,OpenSSL::Digest::MD5.new()      # Hash function
)
pbes.decrypt()
plain_text = pbes.update(@cipher_text[2]) + pbes.final
print plain_text

Javaで復号する

Javaの場合サードパーティの暗号化サービスプロバイダを導入せずに、Sun JDK付属の暗号化サービスプロバイダのみを使用すると、次のようになります:
import javax.crypto.*;
import javax.crypto.spec.*;
import java.io.*;
import java.security.*;

public class DecryptOpensslPBES {
    private static boolean openSSLBytesToKey(Cipher type, MessageDigest md,
                                             byte[] salt, char[] password,
                                             int ic, byte[] key, byte[] iv) {
        int keyLen =  key.length;
        int ivLen  =  type.getBlockSize();
        byte[] dk = new byte[keyLen+ivLen];

        try {
            byte[] pw = new String(password).getBytes("UTF-8");
            int pos = 0;
            int hashLen = md.getDigestLength();

            while (pos < dk.length) {
                md.update(dk, 0, pos);
                md.update(pw);
                md.update(salt);
                md.digest(dk, pos, hashLen);
                for (int i = 1; i < ic; i++) {
                    md.update(dk);
                    md.digest(dk, pos, hashLen);
                }
                pos += hashLen;
            }
        }
        catch (DigestException e) {
            return false;
        }
        catch (UnsupportedEncodingException e) {
            return false;
        };

        for (int i = 0; i < keyLen; i++)
            key[i] = dk[i];
        for (int i = 0; i < ivLen; i++)
            iv[i] = dk[keyLen + i];
        return true;
    }

    public static void main(String[] args) throws Exception {
        byte header[]     = new byte[8];
        byte salt[]       = new byte[8];
        byte cipherText[] = new byte[8142];
        int  cipherLength;
        PBEKeySpec spec;
        SecretKeySpec key;
        IvParameterSpec param;
        byte rawKey[] = new byte[16];
        byte iv[]     = new byte[16];
        byte plain_text[];

        /* 'Salted__' | SALT(0..7) | CIPHER-TEXT */
        if (System.in.read(header, 0, header.length) <= 0)
            return;
        if (System.in.read(salt, 0, salt.length) <= 0)
            return;
        if ((cipherLength = System.in.read(cipherText,
                                           0, cipherText.length)) <= 0)
        {
            return;
        }

        Cipher cbc = Cipher.getInstance("AES/CBC/PKCS5Padding");
        if (!openSSLBytesToKey(cbc, MessageDigest.getInstance("MD5"),
                               salt, "my secret password".toCharArray(),
                               1, rawKey, iv))
        {
            System.out.println("Cannot execute PBKDF");
            return;
        }
        key = new SecretKeySpec(rawKey, "AES");
        param = new IvParameterSpec(iv);
        cbc.init(Cipher.DECRYPT_MODE, key, param);
        plain_text = cbc.doFinal(cipherText, 0, cipherLength);

        System.out.print(new String(plain_text));
    }
}
「Sun JDKにもパスワードベース暗号のPBE系エンジンがバンドルされているのになぜ使わないのか?」と疑問がわいてきます。残念ながらSun JDK付属の暗号化サービスプロバイダは、AESなど128bitブロック暗号を使う場合、opensslコマンドと互換性のある鍵変換処理をもつPBE系エンジンが用意されていません。そのために次のような手続きを自前で記述しています:
  1. パスワードを鍵に変換する処理を自前で実装する
  2. 鍵の前半128bitを秘密鍵に使い
  3. 鍵の後半128bitをCBCモードのInitialization Vectorに使い暗号化する
ただ、私はJavaを使っていないので、根本的な間違いやもっとシンプルな方法があれば教えてください。(それを言ったらRubyもですね...)

鍵導出関数

ここまで漠然と「パスワードを鍵に変換する」と書いてきたので、具体的にどう変換するのか補足します。

一般的なブロック暗号の秘密鍵は、アルゴリズムによって128bitや256bitなどと長さが決まっています。通常パスワードは長さが定まってない文字列なので、秘密鍵として使うには一定の長さに変換したりする処理が必要です。この変換処理を鍵導出関数(KDF - Key Derivation Function)と言います。

鍵導出関数には様々な方式が考えられますが、一般的にはPKCS #5で定めるPBKDF1 (Password-Based Key Derivation Function 1)やPBKDF2広く利用されているようです。

これらの鍵導出関数は、暗号化用の鍵を得るためのパラメータとして
  • パスワード
  • ランダムに生成したsalt
  • 変換処理のくり返し回数(iteration count)
  • 出力する鍵の長さ
をとります。PBKDF1とPBKDF2の概要をそれぞれ表にまとめました:
鍵導出関数 変換処理の基盤 出力できる鍵長 推奨
PBKDF1 ハッシュ関数(MD2, MD5, SHA-1) 最大128bit(SHA-1は160bit)の鍵を生成
PBKDF2 疑似乱数生成器(HMAC-SHA-1, HMAC-SHA-2など) 最大で 疑似乱数生成器の出力幅 x (2^32-1) の鍵を生成
使用するハッシュ関数や疑似乱数生成器が異なっていたり、変換処理のくり返し回数が異なったりすると、同じパスワードとsaltを入力しても同じ鍵を得られず正しく復号できません。そのため使用する鍵導出関数の仕様とそのパラメータも、暗号の相互運用のためには重要な情報と言えます。 さらにPKCS #5では鍵導出関数の他にも、
  • 暗号化と復号の手続き(Encryption Scheme)を定めるPBES1, PBES2
  • メッセージ認証コード計算の手続き(Message Authentication Scheme)を定めるPBMAC1
が定義されています。このうちPBES1はPBKDF1とペアで使用され、Sun JDK付属のPBEもopensslコマンドもPBES1の手続きに沿って実装されています。PBES1は
  1. PBKDF1で128bitの鍵を導出する
  2. 導出した鍵の前半64bitを秘密鍵に使用し
  3. 導出した鍵の後半64bitをCBCモードのInitialization Vectorに使用して
  4. CBCモードで暗号化する
という手続きを実行します。ただしPBES1は暗号アルゴリズムとしてDESかRC2のみを使用し、秘密鍵は56〜64bitの秘密鍵を使うという仕様なので、AESなど鍵長が128bit以上でブロック長も128bitの暗号アルゴリズムには利用できません。 このためopensslコマンドでは、大枠ではPBES1およびPBKDF1と互換性をもたせつつ、AESなどを使用する場合はPBKDF1をベースに最大384bitの鍵を出力できるよう独自の拡張を行い、
  1. PBKDF1を拡張した鍵導出関数で鍵を導出する
  2. 導出した鍵の前半128〜256bitを秘密鍵に使用し
  3. 導出した鍵の後半128bitをCBCモードのInitialization Vectorに使用して
  4. CBCモードで暗号化する
といった感じに処理を実行しています。つまりこのOpenSSLの独自拡張に対応させるために、Sun JDKのみを使う例で鍵導出関数を別途実装する必要が生じたわけです。

まとめ

パスワードベースで暗号文をやり取りする場合は、
  1. 使用する暗号アルゴリズム
  2. 暗号利用モード
  3. パディングルールなどのパラメータ
  4. 鍵の導出手段
の情報を共有・一致させる必要があり、特に鍵の導出手段はライブラリに隠れて詳細が解らない場合があったりでなにげに注意が必要です。アプリケーションによっては暗号文にこれらの情報を埋め込む場合もありますが、なんにせよどのように共有するか把握する必要があります。

ちなみにopensslコマンドは鍵導出関数に繰り返し回数"1"を指定しているので、辞書攻撃や総当たり攻撃に対して比較的不利な鍵が導出されているのは秘密です。いちおう64bitのsaltがあるので、攻撃1回あたりのコストは2の64乗倍に増加しますが、世間的には繰り返し回数を1,000〜5,000回以上にするのがお勧めみたいです。

あと、ここから重要なオチなんですが、PBKDF1はけっこう前から新規開発するアプリケーションでの採用を推奨されていません。現在は代わりにPBKDF2の採用が推奨されています。これに限らず暗号のアプリケーションまわりの話題は陳腐化が激しいので、定期的に棚卸ししておくと夢見が良さそうですね。