HTTP クライアントを作ってみよう(6) - Digest 認証編 -

前へ << HTTP クライアントを作ってみよう(5) - Basic 認証編 - POP3 クライアントを作ってみよう(1) >> 次へ

Digest 認証 (ダイジェスト認証)

前ページでは Basic 認証を紹介し、セキュリティ面で問題があることを示しました。 その欠点を解消したのが Digest 認証です。

以下の URL では Digest 認証を行っています。

Digest 認証の例: http://X68000.q-e-d.net/~68user/net/sample/http-auth-digest/secret.html
(ユーザ名: hoge、パスワード: fuga で閲覧できます)
ブラウザから操作する分には Basic 認証と区別が付かないでしょうが、 認証の仕組みはちょっと複雑になっています。そのかわり、 ネットワーク上を流れるパケットを覗き見られても、パスワードがばれることはありません。
以下、仕組みを簡単に説明します。 「メッセージダイジェスト」の意味がよくわからなければ、暗号化のお話 (3) を参照してください (「ハッシュ」と「メッセージダイジェスト」は同じものと考えてください)。
  • まず、あらかじめサーバ側にパスワードの MD5 メッセージダイジェストを保存しておきます (ユーザ登録に相当)。
  • クライアントが Digest 認証を行うページにやってくると、サーバはクライアントにランダムな文字列を渡します。
  • クライアントはパスワードの MD5 メッセージダイジェストを生成します。
  • さらにクライアントは、生成したメッセージダイジェストの末尾に、サーバから受け取ったランダムな文字列をくっつけて、 ひとつの文字列にします。
  • クライアントは、生成した文字列全体の MD5 メッセージダイジェストをサーバに送信します。 なお、このときサーバから送られてきたランダムな文字列もそのまま送り返します。
  • サーバは、あらかじめ用意してあったパスワードの MD5 メッセージダイジェストの末尾に、 クライアントから送られてきたランダムな文字列 (元々はサーバが生成したもの) をくっつけて、 ひとつの文字列にします。
  • サーバは、この文字列全体の MD5 メッセージダイジェストを生成し、 クライアントから送信されてきたメッセージダイジェストと比較します。 もし一致したら認証成功・一致しなかったら失敗です。
まず、Basic 認証とは異なり、ネットワーク上を生パスワードが流れることはありませんので、 盗聴に対して安全です。また、サーバ側に生パスワードを保存する必要がないのもポイントです。
メールで使用される APOP プロトコルでは、サーバ側に生パスワードを保存する必要があるという欠点があります。 POP3 クライアントを作ってみよう(3) 参照。
基本的な考え方は以上ですが、実際の Digest 認証はパラメータの種類も多く、少し複雑です。

telnet で確認

これまた telnet で http://X68000.q-e-d.net/~68user/net/sample/http-auth-digest/secret.html にアクセスしてみましょう。
% telnet X68000.q-e-d.net 80
GET /~68user/net/sample/http-auth-digest/secret.html HTTP/1.0 (リターン) 
Host: X68000.q-e-d.net:80 (リターン)
(続けてリターン)
HTTP/1.1 401 Authorization Required
Date: Fri, 03 Dec 2004 15:43:35 GMT
Server: Apache/2.0.52 (FreeBSD)
WWW-Authenticate: Digest realm="Secret Zone",
   nonce="RMH1usDrAwA=6dc290ea3304de42a7347e0a94089ff5912ce0de",
   algorithm=MD5, qop="auth"
Content-Length: 471
Connection: close
Content-Type: text/html; charset=iso-8859-1

<!DOCTYPE HTML PUBLIC "-//IETF//DTD HTML 2.0//EN">
<html><head>
<title>401 Authorization Required</title>
</head><body>
<h1>Authorization Required</h1>
<p>This server could not verify that you
are authorized to access the document requested.
(略)
Basic 認証では
WWW-Authenticate: Basic realm="Secret Zone"
だったものが、Digest 認証では
WWW-Authenticate: Digest realm="Secret Zone",
  nonce="RMH1usDrAwA=6dc290ea3304de42a7347e0a94089ff5912ce0de",
  algorithm=MD5, qop="auth"
とかなり長くなっています。まず、
WWW-Authenticate: Digest ...
となっていることから、Digest 認証であることがわかります。後に続く値の意味を解説します。
realm="Secret Zone"
Realm (レルム) です。とりあえず認証ダイアログに表示される メッセージだと思っていてください。
nonce="RMH1usDrAwA=6dc290ea3304de42a7347e0a94089ff5912ce0de"
サーバ側が生成するランダムな文字列です。 nonce は「その場限りの」「一回限りの」という意味の英単語で、「ナンス」と発音します (たぶん)。 これが Digest 認証のキモです。文字通り、この値はアクセスするたびに毎回変わります。 このデータが何なのかをクライアント側が知る必要はありません (実装依存です)。
algorithm=MD5
ダイジェスト文字列を生成するためのアルゴリズムです。現時点では「MD5」と「MD5-sess」が定義されています。 この解説では「MD5」のみ考慮します。
qop="auth"
qop は「quality of protection」の略です。直訳すれば「保護の品質」なんですが、 保護といっても暗号化するわけではありませんので、 当ページ管理人は「保証レベル」という言い方がしっくりきます。 現時点では「auth」と「auth-int」という値が定義されています。 「auth」は認証のみ、「auth-int」はボディ部も含めた認証を行います。 規格としては
qop="auth,auth-int"
といような書き方もありえます。

ただし、この解説では「auth」のみを考慮します。

クライアントが返すべき Authorization ヘッダ

Digest 認証を行なうには、クライアントがサーバに以下のような Authorization ヘッダを付加したリクエストを送付します。
GET /~68user/net/sample/http-auth-digest/secret.html HTTP/1.1
Authorization: Digest username="hoge", realm="Secret Zone",
   nonce="RMH1usDrAwA=6dc290ea3304de42a7347e0a94089ff5912ce0de",
   uri="/~68user/net/sample/http-auth-digest/secret.html", algorithm=MD5,
   qop=auth, nc=00000001,  cnonce="e79e26e0d17c978d",
   response="0d73182c1602ce8749feeb4b89389019"
Host: X68000.q-e-d.net
これまた長いので、ひとつずつ解説します。
username="hoge"
認証ダイアログで入力したユーザ名です。
realm="Secret Zone"
Realm は、サーバから受け取った Realm をそのまま返します。
nonce="RMH1usDrAwA=6dc290ea3304de42a7347e0a94089ff5912ce0de"
nonce も、サーバから受け取ったものをそのまま返します。
uri="/~68user/net/sample/http-auth-digest/secret.html"
uri にはリクエストを行なう URI をセットします。 基本的には
GET URI HTTP/1.0
というリクエストによってサーバに URI が通知されるので、これは冗長な情報です。 しかし、クライアントが proxy サーバ経由でサーバにアクセスしている場合、 クライアントは proxy サーバに
GET http://host/path/file HTTP/1.0
というリクエストを送り、それを受けた proxy サーバは web サーバに
GET /host/path/file HTTP/1.0
というリクエストを送るため、URI が最終的に変化してしまいます。 URI が変わるとメッセージダイジェストの値も変わるので、認証が通らなくなってしまいます。 よって、わざと冗長な情報を Authorization ヘッダに含めているのです。
algorithm=MD5
algorithm も、サーバから受け取ったものをそのまま返します。 ダブルクォートで囲まないことに注意してください。
qop=auth
サーバから受け取った qop のうち、ひとつだけ選択して返します。 サーバから送られてくる qop は「qop="auth"」とダブルクォートで囲まれていますが、 クライアントが返す qop は「qop=auth」とダブルクォートで囲まないことに注意してください。
nc=00000001
nonce-count。リクエストの際に、特定の nonce 値を使ってクライアントが送ったリクエスト数です (16進数 8桁)。 nc=00000001 から始まり、nc=00000009 … 0000000A … 0000000F … 00000010 とカウントアップされていきます。
cnonce="e79e26e0d17c978d"
クライアント側が生成するランダムな文字列です (cnonce は たぶん Client NONCE)。 この値はネットワーク上を平文で流れます。 となると、ランダム文字列を生成する意味がないように思えますが、 選択平文攻撃 (Chosen plaintext attack) への耐性を高める効果があります。
response="0d73182c1602ce8749feeb4b89389019"
パスワードを含む文字列のダイジェスト文字列 (の 16進表記) です。 生成方法は後述。
つまり、クライアント側が送信するデータのうち、 クライアント側が生成しなければならないものは
  • nc=00000001
  • cnonce="e79e26e0d17c978d"
  • response="0d73182c1602ce8749feeb4b89389019"
の 3つとなります。それ以外はサーバから受信したデータを流用します。 nc は内部でカウントすればよく、cnonce はランダムに生成すればよいです。 後は response ですが、これはちょっと複雑です。

response 生成方法

RFC 2617 によると、response の生成方法は以下のとおりです。
 request-digest  = <"> < KD ( H(A1),     unq(nonce-value)
                                     ":" nc-value
                                     ":" unq(cnonce-value)
                                     ":" unq(qop-value)
                                     ":" H(A2)
                             ) <">
 A1       = unq(username-value) ":" unq(realm-value) ":" passwd
 A2       = Method ":" digest-uri-value
さらに、以下のような説明があります (以下の訳は StudyingHTTP の RFC 2617 日本語訳 を参考にさせていただきました)。
秘密の "secret" を持つデータ "data" にダイジェストアルゴリズムを適用する事によって得られる文字列は KD(secret, data) と表現する。また、データ "data" にチェックサムアルゴリズムを適用する事によって得られる文字列は H(data) として表現する。記法 unq(X)は、そのまわりに引用符のない quoted-string X の値を意味する。

"MD5" 及び "MD5-sess" アルゴリズムにおいて

H(data) = MD5(data)
であり、また
KD(secret, data) = H(concat(secret, ":", data))
である

一読しただけでは何が何だかわかりませんが、 頭をからっぽにして、書いてあることをそのまま素直に整理すると以下のようになります。

response の値は

「A1のMD5値 + ":" + nonce値 + ":" + nc値 + ":" cnonce値 + ":" + qop値 + ":" + A2のMD5値」の MD5 値
です。この A1 とは
「username + ":" + realm + ":" + passwd」の MD5 値
のことで、A2 は
「Method + ":" + uri値」(Method は HTTP の GET や POST のこと)
です。いずれの値も両端をダブルクォートで囲みません。
ちなみに A1 は、サーバ側の Digest 認証用パスワードファイルに格納されている文字列と同じです。

こういうものは説明するよりコードを示した方が早いですね。

#!/usr/bin/perl

# Digest::MD5 を使用する。
use Digest::MD5 qw(md5_hex);

# あらかじめ or リクエストを送信するときまでにわかっている値
$username ='hoge';
$passwd   ='fuga';
$method   = 'GET';
$uri      = "/~68user/net/sample/http-auth-digest/secret.html";

# サーバ側が生成する値
$nonce    = "RMH1usDrAwA=6dc290ea3304de42a7347e0a94089ff5912ce0de";
$realm    = 'Secret Zone';
$qop      = 'auth';

# クライアント側が生成する値
$nc       = '00000001';         # nonce-count
$cnonce   = 'e79e26e0d17c978d'; # ランダム文字列

# response を生成
$a1 = "$username:$realm:$passwd";
$h_a1 = md5_hex($a1);
$a2 = "$method:$uri";
$h_a2 = md5_hex($a2);
$response = "$h_a1:$nonce:$nc:$cnonce:$qop:$h_a2";
$h_response = md5_hex($response);

# 結果表示
print "\$a1=$a1\n";
print "\$h_a1=$h_a1\n";
print "\$a2=$a2\n";
print "\$h_a2=$h_a2\n";
print "\$response=$response\n";
print "\$h_response=$h_response\n";
Digest::MD5 は perl-5.6 か 5.8 あたりから標準モジュールとなっているはずです。 もし Digest::MD5 モジュールがインストールされていないなら、
$h_a1 = md5_hex($a1);
chomp($h_a1 = `printf "%s" "$a1" | md5`);
というふうに外部コマンドを使うように書き換えるとよいでしょう (もし md5 コマンドがないなら md5sum を試してください)。 なお、外部コマンドを使う書き方は、悪意のある web サーバにダブルクォートを含む realm を送られたりすると、クライアント側で自由なコードの実行を許してしまいます。 あくまでテスト目的で行ってください。

Digest 認証対応クライアント

Digest 認証に対応したクライアントです。ただし、以下の点でかなりの手抜きをしていますので、 そのつもりで。
  • URL・ユーザ名・パスワードがべた書き。
  • WWW-Authenticate をカンマで split している。realm にカンマが入っていたらまずい。
  • nc・cnonce を決め打ち。
  • algorithm=MD5・qop="auth" を前提としている。

http-client-auth-digest.pl

    1: #!/usr/local/bin/perl -w
    2: 
    3: # $Id: http-client-auth-digest.pl,v 1.2 2005/02/19 16:01:53 68user Exp $
    4: #
    5: # Digest 認証 手抜きクライアント。Digest::MD5 モジュール必須。
    6: #
    7: #      written by 68user  http://X68000.q-e-d.net/~68user/
    8: 
    9: use strict;
   10: use Socket;
   11: use Digest::MD5 qw(md5_hex);
   12: 
   13: my $host     = 'X68000.q-e-d.net';
   14: my $uri      = '/~68user/net/sample/http-auth-digest/secret.html';
   15: my $username = 'hoge';    # ユーザ名
   16: my $passwd   = 'fuga';    # パスワード
   17: my $method   = "GET";
   18: 
   19: my $connect_host = $host;
   20: my $port = getservbyname('http', 'tcp') || 80;
   21: my $iaddr = inet_aton($connect_host) || die "$connect_host は存在しないホストです。\n";
   22: my $sock_addr = pack_sockaddr_in($port, $iaddr);
   23: 
   24: socket(SOCKET, PF_INET, SOCK_STREAM, 0) || die "ソケット を生成できません。\n";
   25: connect(SOCKET, $sock_addr) || die "$connect_host の ポート $port に接続できません。\n";
   26: select(SOCKET); $|=1; select(STDOUT);
   27: 
   28: my $request = '';
   29: $request .= "$method $uri HTTP/1.0\r\n";
   30: $request .= "Host: $host\r\n";
   31: $request .= "\r\n";
   32: 
   33: print "--- 1st requst ---\n$request---\n";
   34: print SOCKET $request;
   35: 
   36: my %auth_info;
   37: while (<SOCKET>){
   38:     print "1st response: $_";
   39:     if ( m/^WWW-Authenticate: Digest (.*)/i ){
   40:         # カンマで分割 (手抜き)
   41:         foreach (split(",",$1)){
   42:             s/^\s*//;           # 先頭の空白を削る
   43:             s/\s*$//;           # 末尾の空白を削る
   44:             my ($key, $value) = m/(.*?)=(.*)/;
   45:             $value =~ s/^\"//;  # 先頭のダブルクォートを削る (手抜き)
   46:             $value =~ s/\"$//;  # 末尾のダブルクォートを削る (手抜き)
   47:             print "  [$key]=[$value]\n";
   48:             $auth_info{$key}=$value;
   49:         }
   50:     }
   51: }
   52: close(SOCKET);
   53: 
   54: # response 生成
   55: my $nc       = '00000001';         # 決め打ち
   56: my $cnonce   = 'e79e26e0d17c978d'; # ランダムな文字列にすべきだが、ここでは決め打ち
   57: 
   58: my $a1 = "$username:$auth_info{realm}:$passwd";
   59: my $h_a1 = md5_hex($a1);
   60: my $a2 = "$method:$uri";
   61: my $h_a2 = md5_hex($a2);
   62: my $response="$h_a1:$auth_info{nonce}:$nc:$cnonce:$auth_info{qop}:$h_a2";
   63: my $h_response = md5_hex($response);
   64: 
   65: print "a1=[$a1]\n";
   66: print "h_a1=[$h_a1]\n";
   67: print "a2=[$a2]\n";
   68: print "h_a2=[$h_a2]\n";
   69: print "response=[$response]\n";
   70: print "h_response=[$h_response]\n";
   71: 
   72: # 2回目の接続
   73: 
   74: socket(SOCKET, PF_INET, SOCK_STREAM, 0) || die "ソケット を生成できません。\n";
   75: connect(SOCKET, $sock_addr) || die "$connect_host の ポート $port に接続できません。\n";
   76: select(SOCKET); $|=1; select(STDOUT);
   77: 
   78: $request = '';
   79: $request .= "$method $uri HTTP/1.0\r\n";
   80: $request .= "Host: $host\r\n";
   81: $request .= qq(Authorization: Digest username="$username",);
   82: $request .= qq(  realm="$auth_info{realm}",);
   83: $request .= qq(  nonce="$auth_info{nonce}",);
   84: $request .= qq(  uri="$uri",);
   85: $request .= qq(  algorithm=$auth_info{algorithm},);
   86: $request .= qq(  response="$h_response",);
   87: $request .= qq(  qop=auth, nc=$nc,);
   88: $request .= qq(  cnonce="$cnonce"\r\n);
   89: $request .= "\r\n";
   90: 
   91: print "\n--- 2nd requst ---\n$request---\n";
   92: print SOCKET $request;
   93: 
   94: while (<SOCKET>){
   95:     print "2nd response: $_";
   96: }
   97: close(SOCKET);

web サーバ対応状況

web サーバ側は、Apache・IIS など有名どころは Digest 認証に対応しているでしょう。 ただし少なくとも Apache-2.0.53 では設定ファイル httpd.conf の
#LoadModule auth_digest_module libexec/apache2/mod_auth_digest.so
のコメントを外して有効にし、Apache を再起動する必要があります。

クライアント対応状況

一昔前のブラウザは、Digest 認証にはほとんど対応していませんでした。 しかし IE5・Mozilla-0.9 近辺から実装され始め、いまどきの web サーバ・ブラウザであれば、 ほぼ対応していると言ってもよいでしょう。

ただし、IE6 では Digest 認証を使って foo.cgi?FOO=BAR のような URL にリクエストすると、 誤ったリクエストを送ってしまうバグがあります。 以下の URL は Mozilla や FireFox では正しく閲覧できますが、IE6 だと正しいユーザ名・パスワードを送信しても、 400 Bad Request になってしまいます (Windows XP SP2 + IE6 で確認)。

サンプル: http://X68000.q-e-d.net/~68user/net/sample/http-auth-digest/secret.cgi?FOO=BAR
(ユーザ名: hoge、パスワード: fuga で閲覧できます)
パケットを見てみると、IE が送信した Authorization ヘッダの uri には「?FOO=BAR」の引数部分が含まれていないようです。 その結果、Apache のログには以下のように出力されます。
[error] [client 192.168.1.11] Digest: uri mismatch - </~68user/net/sample/http-auth-digest/secret.cgi>
   does not match request-uri </~68user/net/sample/http-auth-digest/secret.cgi?FOO=BAR>
これは IE のバグですが、いつまでたっても修正されないので、 Apache-2.0.51 からサーバ側で無理矢理対応する仕組みが設けられました。 httpd.conf や .htaccess に以下のように記述しておけば、 IE でも上記のような URL に正しくアクセスできます。
# User-Agent ヘッダに MSIE という文字列が含まれていたら、
# 環境変数 AuthDigestEnableQueryStringHack に「On」をセットする。
BrowserMatch "MSIE" AuthDigestEnableQueryStringHack=On
上記の設定がなされていた場合、まず正規の方法で認証を行い、 それに失敗した場合に IE 用の認証処理が行われます。 よって、今後 IE のバグが修正されたり、IE 以外のブラウザの User-Agent に「MSIE」という文字列が含まれていたとしても、問題は起きないと思われます。

なお、IE7 ではこのバグは修正されています。

どうでもいいですが、IE6 が送る Authorization ヘッダは qop="auth" となっています。 しかし正しくは qop=auth です (ダブルクォートは付けない)。

まず、qop の値である auth や auth-int は token です。token はダブルクォートで囲む必要はありません。

しかし、サーバが出力する WWW-Authentication ヘッダの qop には複数の値が入る可能性があり、 その場合はカンマ区切りにしなければならないので qop="auth,auth-int" や qop="auth" のように常にダブルクォートで囲って「文字列」として扱います。

しかしクライアントが送る Authorization ヘッダの qop は、サーバが提示した qop の中からひとつを選んで送り返すので、ここでの qop の値は token となります。 よって、クライアントが送る qop はダブルクォートを付けてはいけないのです。

ファイル設定

今回のサンプルで使用した認証関連の設定ファイルは以下の通りです。 今回も .htaccess と .htdigest を参照できるようにしています。また、 あえてパスワードファイルを public_html 以下に置いています。
  • .htaccess
    apache 設定ファイル。.htaccess と .htdigest を公開するため、allow from all としている。 AuthType ディレクティブは Basic ではなく Digest とすること。また、AuthUserFile ディレクティブではなく AuthDigestFile ディレクティブを使うことに注意 (であったが、Apache 2.2 よりダイジェスト認証でも AuthDigestFile ではなく AuthUserFile を使うようになった模様)。
  • .htdigest
    パスワードファイル。以下のように htdigest コマンド (htpasswd コマンドではない) を使用して作成したもの (レルム: "Secret Zone"、ユーザ名: hoge、パスワード: fuga)。
    % htdigest -c .htdigest "Secret Zone" hoge
    New password: (fuga と入力)
    Re-type new password: (fuga と入力)
    Adding password for user hoge
    
  • secret.html
    秘密のファイル (ユーザ名: hoge、パスワード: fuga)
  • secret.cgi
    秘密の CGI プログラム (ユーザ名: hoge、パスワード: fuga)

注意

実際に Digest 認証を行う場合は (特に不特定多数を相手にするサーバ・クライアントを作成する場合)、 必ず RFC 2617 を読んでください。本ページではたとえば以下のことがらについて説明していません。
  • Obsolete となった RFC 2069 との互換性
  • algorithm="MD5-sess"
  • qop="auth-int"
  • サーバから qop を指定されている場合は、クライアントは cnonce・nc を送信しなければならないが、 そうでない場合は送信してはいけない、など
  • 認証成功時にサーバからクライアントに渡される Authentication-Info ヘッダ
前へ << HTTP クライアントを作ってみよう(5) - Basic 認証編 - POP3 クライアントを作ってみよう(1) >> 次へ

ご意見・ご指摘は Twitter: @68user までお願いします。