キーワード  
CGI  暗号  2016年08月22日 12:21   編集
パスワードの暗号化についての覚書
パスワードを保存する必要のあるCGIでは、生のパスワードのまま保存するより、暗号化したほうが安心ということでこれまですべてcrypt関数で暗号化した上で保存していました。
このcrypt関数は手軽に使えて便利なのですが、有効なパスワードの文字数が8文字までという制限があります。

たとえば、「password123」というパスワードをcryptで暗号化して保存すると、9文字以降は無視されるため、「password」でも、「password012」でも正しいパスワードと認識されてしまいます。

Digest::MD5モジュールを使う

これは、やはりマズイので、何とかできないか調べてみました。
perlのモジュールでDigest::MD5というのを使う方法があるようです。
これを使うと8文字以上のパスワードでも使えるし、暗号としての強度も高いようです。
perlのバージョン5.8以降は標準で入っているということですが、入っていない場合は処理を分ける必要があります。
Digest::MD5が入っているか、あるいはperlのバージョンが5.8以降であるかどうをユーザーに調べてもらって、cryptかMD5どちらを使うか選択してもらうというのは、わかりにくいと思うので、CGIで自動的に選択する方法を考えてみました。
まず、モジュールがインストールされているかどうか調べる方法ですが、モジュール関係は@INCという配列にライブラリのパスが保存されているので、このなかに「MD5.pm」というファイルが存在するかどうかを調べればよさそうです。しかし、@INCに保存されているディレクトリ直下にあるとは限らないので、サブディレクトリもすべて調べなければならないようです。
use File::Find;
find(\&search, @INC);
sub search {
    if ($_ eq 'MD5.pm') {
        $md5ok = 1;
        last;
    }
}
のような処理で'MD5.pm'ファイルがあるかどうか調べられますが、結構時間がかかります。
perlのバージョンで分岐する方式だと、
if ($] >= 5.8) {
    $md5ok = 1;
}
と簡単で、あっという間に終わります。
しかし、このperlのバージョンが今ひとつよくわかりません。
たとえば、ウチの環境では、CGIテスト用にローカルにインストールしているXAMPPのperlのバージョンは5.16.3ということになっていますが、
perlの$]では5.016003ということになります。そして、MD5モジュールは入っています。

ということは、分岐の条件は
$] >= 5.008
にすべきなのかとかよくわからないので、結局'MD5.pm'があるかどうかで決めることにしました。
※(その後の調査で、Perlバージョン5.8以降かどうかの分岐は$] >= 5.008 でいいことがわかりました。
以下モジュール内を検索する方法を説明していますが、Perlのバージョンで分岐した方がかなり簡単です。)
といっても、処理に時間がかかるので、パスワード生成や照合のたびに(特に照合)調べるのは避けたいところです。
設定ファイルinit.cgiで暗号化方法を$codeに保存することにして、$codeが未設定の場合だけ
モジュール内を検索してその結果をinit.cgiに書き込むことにしました。
MD5を使った暗号化と照合処理についてはKENT-WEBの暗号化入門を参考にさせてもらいました。

crypt流用

MD5が使えない環境では今まで通り8文字までというのも、気になるのでついでにcrypt使って8文字以上使える方法も考えてみました。
手順としては、パスワード保存する場合
まず、通常通りcrypt処理して暗号化した文字列を$cypted1とします。
これは、入力されたパスワードの8文字目分しか見ていません。
入力したパスワードが8文字以上だった場合、9文字目以降を取り出して再度crypt処理してこれを$cypted2とします。
$cypted1、$cypted2それぞれ保存してもいいのですが、cryptで暗号化された文字列は13文字と決まっているようなので、保存、読み込みが一回で済むように

$cypted = $cypted1 . $cypted2;

としてまとめて保存することにします。
照合処理は、
入力されたパスワードが8文字以上の場合は、通常の照合作業に加えて、
9文字目以降分の照合処理を追加します。その照合に使用する暗号化文字列は$cyptedに保存された文字列の14文字以降ということになります。
環境や入力したパスワードの長さによって暗号化パスワード処理が違い、当然照合処理も違うことになりますが、照合処理の分岐は暗号化文字列の長さによってのみ振り分けるのが良さそうです。
MD5で暗号化するよう設定したとしても、crypt方式で暗号化した登録はその方式で照合しなければならないからです。

暗号化文字列が13文字の場合は従来のcrypt照合
暗号化文字列が26文字の場合は従来のcrypt照合2回
暗号化文字列が40文字の場合はMD5照合。

実装

暗号作成処理と照合処理は以下のようなものにしました。

暗号化
sub encrypt {
    my($inpw) = $_[0];
    if ($inpw =~ /([$ban])/) {
        &error("パスワードエラー","「$1」はパスワードに含めないでください。");
    }
    my $encrypt;
    if ($code == 2) {
        use Digest::MD5 qw/md5_hex/;
        my @str = ('a' .. 'f', 0 .. 9);
        my $salt;
        for (1 .. 8) {
            $salt .= $str[int(rand(@str))];
        }
        $encrypt = $salt . md5_hex($salt . $inpw);
    } else {
        my(@SALT, $salt);
        @SALT = ('a'..'z', 'A'..'Z', '0'..'9');
        srand(int(rand(100000)));
        $salt = $SALT[int(rand(@SALT))] . $SALT[int(rand(@SALT))];
        $encrypt = crypt($inpw, $salt) || crypt ($inpw, '$1$' . $salt);
        if (length($inpw) > 8) {
            my $inpw2 = substr($inpw,8,8);
            srand(int(rand(100000)));
            my $salt2 = $SALT[int(rand(@SALT))] . $SALT[int(rand(@SALT))];
            my $encrypt2 = crypt($inpw2, $salt2) || crypt ($inpw2, '$1$' . $salt2);
            $encrypt .= $encrypt2;
        }
    }
    $encrypt;
}

照合
sub decrypt {
    my($inpw, $logpw) = @_;
    my($salt, $check);
    if (length($logpw) == 40) {
        # saltは先頭の8文字を抜き出す
        my $salt = substr($logpw, 0, 8);
        $check = "no";
        # 照合
        if ($logpw eq ($salt . md5_hex($salt . $inpw))) {
            $check = "yes";
        }
    } else {
        my $logpw1 = substr($logpw,0,13);
        my $inpw1 = substr($logpw,0,8);
        $salt = $logpw1 =~ /^\$1\$(.*)\$/ && $1 || substr($logpw1, 0, 2);
        $check = "no";
        if (crypt($inpw, $salt) eq $logpw1 || crypt($inpw1, '$1$' . $salt) eq $logpw1) {
            $check = "yes";
        }
        if ($check eq "yes" && length($logpw) == 26) {
            my $logpw2 = substr($logpw,13,13);
            my $inpw2 = substr($inpw,8,8);
            my $salt2 = $logpw2 =~ /^\$1\$(.*)\$/ && $1 || substr($logpw2, 0, 2);
            if (crypt($inpw2, $salt2) eq $logpw2 || crypt($inpw2, '$1$' . $salt2) eq $logpw2) {
                $check = "yes";
            } else {
                $check = "no";
            }
        }
    }
    $check;
}
スクリプトに実装するには、このsub encryptとsub decryptを古いものと入れ換えることになりますが、その前提として、スクリプトで使用する暗号化方式を決めておく必要があります。
sub encryptで使用されている$codeです。
MD5方式で暗号化する場合は
$code = 2;
cryptを使用する場合は
$code = 1;
としておきますが、これは前述のように、インストールされているモジュールを検索してMD5.pmが存在すれば、initファイルなり、スクリプトファイル自身なりに自動的に書き込みます。
この処理はスクリプトによって多少違いますが、multiupload.cgiの場合は

if (! $code) {
    # 暗号化方法が未定の場合のみスクリプトファイルを書き換え
    use File::Find;
    # MD5モジュールがインストールされているかどうか調べる
    my $md5ok;
    find(\&mdsearch, @INC);
    sub mdsearch {
        if ($_ eq 'MD5.pm') {
            $md5ok = 1;
            return $md5ok;
        }
    }
    &lock;
    open(SCR,$script) || &error("$scriptが開けません。");
    my @scr = <SCR>;
    close(SCR);
    
    my $code_str;
    if ($md5ok) {
        $code_str = 'my $code = 2;' . "\t" x 9 ."# 暗号化方法(1:crypt 2:MD5)\n";
    } else {
        $code_str = 'my $code = 1;' . "\t" x 9 ."# 暗号化方法(1:crypt 2:MD5)\n";
    }
    open(SCR,">$script");
    my $flag;
    foreach (@scr) {
        if (/^\s*my\s+\$code\s*=/) {
            print SCR $code_str;
            $flag = 1;
        } else {
            if (! $flag && /^\s*my\s+\$adminpass\s*=/) {
                print SCR $code_str;
            }
            print SCR $_;
        }
    }
    close(SCR);
    &unlock;
}

のような感じです。
これでほぼOKですが、さらに、9文字以上のパスワードを使用する管理者やユーザーがログインした際、旧方式で暗号化されたパスワードしか保存されていない場合(つまり暗号化パスワードの文字数が13文字の場合)9文字以降の文字も有効にするために、再度パスワードを登録するよう促す文章を表示するようにしました。
counter:2,467