コンテンツにスキップ

Digest 認証

Digest 認証 — RFC 7616

  • Digest 認証は Basic 認証と同じ HTTP 認証フレームワーク (RFC 9110 §11) 上のスキームで、仕様は RFC 7616(RFC 2617 を廃止)。
  • サーバーが渡した nonce を混ぜたハッシュを送るので、パスワードそのものは経路に出ない。使い回しも nc(nonce カウント)で防ぐ。
  • それでも実務で選ばれない。サーバーは H(A1) を保存しなければならず、これがパスワード等価だから。ハッシュ化保存の利点が消える。

HTTP 認証フレームワークの中の位置づけ

Section titled “HTTP 認証フレームワークの中の位置づけ”

枠組みは Basic とまったく同じ。401 + WWW-Authenticate でチャレンジし、Authorization で応答する。違うのは「Authorization に何を載せるか」だけ。

flowchart TB
    subgraph FW["HTTP 認証フレームワーク (RFC 9110 §11)"]
        direction TB
        C["チャレンジ<br/>401 + WWW-Authenticate"]
        R["クレデンシャル送信<br/>Authorization ヘッダ"]
        C --> R
    end

    FW --> S1["Basic (RFC 7617)<br/>パスワードを Base64 で載せる"]
    FW --> S2["Digest (RFC 7616)<br/>nonce を混ぜたハッシュを載せる"]
    FW --> S3["Bearer (RFC 6750)<br/>トークンを載せる"]

    style S2 fill:#4054b2,color:#fff

Basic が「毎回パスワードを送る」のに対し、Digest は「パスワードを知っていることを証明する値を送る」。この置き換えだけで、経路の盗聴に対しては強くなる。ただし後述のとおり、代償としてサーバー側の保存が弱くなる。

Basic と違い、サーバーから nonce をもらうまでクレデンシャルを組み立てられない。したがって先回り送信ができず、最初の往復が必ず発生する。

sequenceDiagram
    autonumber
    participant B as ブラウザ
    participant S as サーバー

    B->>S: GET /private/ (Authorization なし)
    S-->>B: 401 Unauthorized<br/>WWW-Authenticate: Digest<br/>realm, qop, nonce, algorithm
    Note over B: nonce を受け取って初めて<br/>計算材料が揃う
    B->>B: cnonce を生成し nc=00000001 に
    B->>B: H(A1) と H(A2) を求め<br/>response を計算
    B->>S: GET /private/<br/>Authorization: Digest<br/>username, nonce, nc, cnonce, response
    S->>S: 保存済みの H(A1) で<br/>同じ計算をして突き合わせる

    alt 一致し nonce も有効
        S-->>B: 200 OK<br/>Authentication-Info: rspauth, nextnonce
        Note over B: rspauth を検証すると<br/>サーバー側も本物だと分かる
    else nonce の期限切れ
        S-->>B: 401 + stale=true
        Note over B: 利用者に再入力させず<br/>新しい nonce で再送する
    else 不一致
        S-->>B: 401 (再チャレンジ)
    end

stale=true の分岐が Digest 特有。nonce には寿命があるので、正しいパスワードでも期限切れで弾かれる。このとき stale=true を付けると、クライアントはダイアログを出さずに新しい nonce で再計算する。付け忘れると、利用者には「正しいパスワードを入れたのに再入力を求められた」ように見える。

チャレンジとクレデンシャルのパラメータ

Section titled “チャレンジとクレデンシャルのパラメータ”
WWW-Authenticate: Digest realm="Staging Area", qop="auth", algorithm=SHA-256,
nonce="7ypf/xlj9XXwfDPEoM4URrv/xwf94BcCAzFZH4GiTo0v", opaque="FQhe/qaU925kfnzjCev0ciny7QMkPqMAFRtzCUYo5tdS",
charset=UTF-8, userhash=true
パラメータ 必須 意味
realm 実質必須 保護空間の名前。H(A1) の計算に入るので、変えると全パスワードが無効になる
nonce 実質必須 サーバーが毎回変える使い捨ての値。リプレイ防止の核
qop 必須 auth または auth-int。RFC 7616 で必須になった(RFC 2617 では任意)
algorithm 任意 MD5 / SHA-256 / SHA-512-256 と各 -sess 版。省略時は MD5
opaque 任意 サーバーの状態。クライアントは中身を解釈せずそのまま返す
stale 任意 true なら「nonce が古いだけ。パスワードは合っている」
domain 任意 この認証が通用する URI の一覧
charset 任意 指定できる値は UTF-8 のみ
userhash 任意 true なら利用者名をハッシュ化して送ってよい
Authorization: Digest username="demo", realm="Staging Area", uri="/private/",
algorithm=SHA-256, nonce="7ypf/xlj9XXwfDPEoM4URrv/xwf94BcCAzFZH4GiTo0v",
nc=00000001, cnonce="f2/wE4q74E6zIJEtWaHKaf5wv/H5QzzpXusqGemxURZJ", qop=auth,
response="3d78f6dba3c48ccb424d50225bac0f815d8466a521cb96d23020a63948cc84b1",
opaque="FQhe/qaU925kfnzjCev0ciny7QMkPqMAFRtzCUYo5tdS", userhash=false
パラメータ 必須 意味
response 必須 計算したハッシュ。これが認証の本体
username / username* 必須 利用者名。非 ASCII は username*、隠すなら userhash
realm / uri / nonce 必須 チャレンジで受け取った値をそのまま返す
qop サーバーが指定したら必須 authauth-int
cnonce 必須 クライアント側の使い捨て値。サーバーだけに値を選ばせない
nc 必須 同じ nonce で送った回数。16 進 8 桁固定00000001 から
opaque 受け取ったら必須 そのまま返す

Digest でわかりにくいのは「何をどう混ぜて response にするか」。仕組みを一言でいうとこうなる。

サーバーから使い捨ての nonce をもらい、手元のパスワードと混ぜて、混ぜた結果だけを送る

パスワードは手元から出ず、経路に出るのは混ぜた結果だけ。 しかも混ぜる材料に使い捨ての値が入っているので、その結果を盗んでも次のリクエストには使えない。以下、実物の値で追う。

username = demo / realm = Staging Area / password = P@ssw0rdGET /private/algorithm=SHA-256qop=auth の場合。

# 段階
1 A1 demo:Staging Area:P@ssw0rd
2 H(A1) 008dc6f7c71911eabf2c1d6b55873886a6e432f18c06d2dc3f067c3135760966
3 A2 GET:/private/
4 H(A2) 84a5bbac9197aaeffe57f4a0309811e8a5d08b82f42d9c54181f15d923076ab7
5 連結 H(A1):nonce:nc:cnonce:qop:H(A2)
6 response 3d78f6dba3c48ccb424d50225bac0f815d8466a521cb96d23020a63948cc84b1
flowchart TB
    IN1["username<br/>realm<br/>password"] --> A1["A1<br/>demo:Staging Area:P@ssw0rd"]
    A1 --> HA1["H(A1)<br/>008dc6f7...0966"]

    IN2["method<br/>request-uri"] --> A2["A2<br/>GET:/private/"]
    A2 --> HA2["H(A2)<br/>84a5bbac...6ab7"]

    IN3["nonce<br/>nc<br/>cnonce<br/>qop"] --> J["連結してハッシュ化"]
    HA1 --> J
    HA2 --> J
    J --> RES["response<br/>3d78f6db...84b1"]

    style IN1 fill:#7a2222,color:#fff
    style RES fill:#1f5c33,color:#fff

押さえどころは 3 つ。

  1. パスワードは A1 の中にしか出てこない。 経路に出るのは response だけで、そこからパスワードを逆算することは(ハッシュなので)できない。
  2. noncenccnonce が入るので、同じパスワードでもリクエストごとに response が変わる。 盗んだ response の再利用が効かない。
  3. A2 にメソッドと URI が入る。 つまり response は「この URI にこのメソッドで」という文脈に縛られる。別の URI に付け替えても通らない。

2. サーバー側は同じ計算を繰り返すだけ

Section titled “2. サーバー側は同じ計算を繰り返すだけ”
flowchart TB
    A["Authorization: Digest ... を受信"] --> B{"nonce は自分が発行したもので<br/>まだ有効か"}
    B -->|"期限切れ"| ST["401 + stale=true<br/>再入力は求めない"]
    B -->|"不正・未知"| NG["401 (再チャレンジ)"]
    B -->|"有効"| C{"この nonce で<br/>その nc は未使用か"}
    C -->|"使用済み"| RP["401<br/>リプレイとして拒否"]
    C -->|"未使用"| D["保存済みの H(A1) を取り出す"]
    D --> E["受け取った nonce nc cnonce qop と<br/>自分で作った H(A2) を連結"]
    E --> F["ハッシュを計算し<br/>response と定数時間で比較"]
    F -->|"不一致"| NG
    F -->|"一致"| G["200 OK<br/>Authentication-Info を付ける"]

    style D fill:#7a2222,color:#fff
    style G fill:#1f5c33,color:#fff

赤で示した H(A1) の取り出しが Digest の弱点。サーバーはこの値を持っていなければ検証できず、しかもこの値だけで認証を突破できる(後述)。

response を自分で計算する
import hashlib
def H(s: str) -> str:
return hashlib.sha256(s.encode()).hexdigest()
user, realm, pw = "demo", "Staging Area", "P@ssw0rd"
method, uri = "GET", "/private/"
nonce = "7ypf/xlj9XXwfDPEoM4URrv/xwf94BcCAzFZH4GiTo0v"
cnonce = "f2/wE4q74E6zIJEtWaHKaf5wv/H5QzzpXusqGemxURZJ"
nc, qop = "00000001", "auth"
ha1 = H(f"{user}:{realm}:{pw}") # 008dc6f7c719...0966
ha2 = H(f"{method}:{uri}") # 84a5bbac9197...6ab7
print(H(f"{ha1}:{nonce}:{nc}:{cnonce}:{qop}:{ha2}"))
# => 3d78f6dba3c48ccb424d50225bac0f815d8466a521cb96d23020a63948cc84b1
curl で送る
# --digest を付けると curl が 401 を受けてから計算して再送する。
# Basic の -u と違い、先回り送信はできない(nonce が必要なため)
curl -v --digest -u demo:'P@ssw0rd' https://example.com/private/
# 実際に 2 往復していることを確認する
curl -sv --digest -u demo:'P@ssw0rd' https://example.com/private/ 2>&1 | grep -E '^(> GET|< HTTP)'
# > GET /private/ HTTP/1.1
# < HTTP/1.1 401 Unauthorized
# > GET /private/ HTTP/1.1
# < HTTP/1.1 200 OK
stateDiagram-v2
    [*] --> Issued
    Issued : 401 で発行
    Active : nc を数えて再利用
    Stale : 期限切れ
    Issued --> Active : 初回の応答が届く
    Active --> Active : nc を 1 進める
    Active --> Stale : 有効期限が過ぎる
    Stale --> Issued : stale で再発行
    Active --> [*] : サーバーが破棄

サーバーは nonce ごとに「どの nc まで使われたか」を覚えておく必要がある。 ここが Basic との決定的な運用差で、Digest はステートフルになる。

RFC 7616 は nonce の作り方の例として time-stamp H(time-stamp ":" ETag ":" private-key) を挙げている。時刻を埋め込んでおけば、期限判定にサーバー側の記憶が要らないという発想。

5. サーバーも名乗る: Authentication-Info

Section titled “5. サーバーも名乗る: Authentication-Info”

Digest には相互認証がある。サーバーは Authentication-InfoRFC 7615)で rspauth を返せる。

Authentication-Info: qop=auth, rspauth="824315e53af346f6e67277510146e73b78904532dccd2b3603b0c83eb7d95b29",
cnonce="f2/wE4q74E6zIJEtWaHKaf5wv/H5QzzpXusqGemxURZJ", nc=00000001,
nextnonce="次のリクエストで使う nonce"

rspauthresponse とほぼ同じ計算で、A2 の method 部分を空にするA2 = ":" request-uri)。これを検証できれば、相手が H(A1) を知っていること、つまり本物のサーバーであることが確認できる。

  • Basic にはこの仕組みがない。 偽サーバーに繋がっても気づけない
  • nextnonce を使うと、クライアントは次の往復で 401 を食わずに済む
  • ただしクライアント側で rspauth を検証している実装は少ない。ブラウザは検証しない

6. 経路に流れるものを Basic と並べる

Section titled “6. 経路に流れるものを Basic と並べる”
Basic Digest
経路に出る値 base64(user:pass) = パスワードそのもの response = ハッシュ
逆算できるか できる(Base64 は可逆) できない
盗んだ値の再利用 できる(有効期限がない) できない(noncenc で防ぐ)
先回り送信 できる できない(nonce が必要)
リクエストの改竄検知 なし URI とメソッドは縛られる。本文は auth-int のときだけ
サーバーの真正性確認 なし rspauth で可能

経路のセキュリティだけを見れば Digest は明確に上。これは押さえておく。問題は次の節。

ここが Digest の評価を決める。 試験でも「Digest のほうが安全か」を問う形で出る。結論を絵にするとこうなる。

Basic はハッシュ化して保存できるので流出しても認証には使えない。Digest は H(A1) を保存するしかないので流出したらそのまま認証できる

同じ「ハッシュを保存している」ように見えて、流出したときに起きることが正反対になる。

flowchart TB
    subgraph BASIC["Basic 認証"]
        direction TB
        B1["サーバーが受け取るのは<br/>平文のパスワード"]
        B2["受け取ってから<br/>Argon2 や bcrypt でハッシュ化"]
        B3["保存するのは<br/>ソルト付き低速ハッシュ"]
        B4["流出しても<br/>そのままでは認証できない"]
        B1 --> B2 --> B3 --> B4
    end

    subgraph DIGEST["Digest 認証"]
        direction TB
        D1["サーバーが受け取るのは<br/>response だけ"]
        D2["検証には H(A1) が必要<br/>H(username:realm:password)"]
        D3["保存するのは<br/>ソルトなし高速ハッシュ"]
        D4["流出したら<br/>H(A1) だけで認証できる"]
        D1 --> D2 --> D3 --> D4
    end

    style B4 fill:#1f5c33,color:#fff
    style D3 fill:#7a4a12,color:#fff
    style D4 fill:#7a2222,color:#fff

Digest の検証には H(A1) が必要で、H(A1)パスワードと等価になる。RFC 7616 §5.2 も次のように書いている。

パスワードファイルは、平文のパスワードを含んでいるものとして保護しなければならない。その realm の文書にアクセスする目的においては、実質的にそうなっているからである。

帰結はこうなる。

  • Argon2 や bcrypt が使えない。 ソルトも段数もない H(username:realm:password) を保存するしかない
  • H(A1) が漏れたら、パスワードを知らなくても認証を通せる。 ハッシュを保存している意味がない
  • realm を変えると全ユーザーのパスワードが無効になる。 H(A1)realm が入っているため。逆に言えば、realm 違いへの横展開は防げる
  • MD5 は GPU で高速に総当たりできるので、Apache 自身のドキュメントが「受動的な盗聴者は今日のグラフィックスハードウェアでパスワードを総当たりできる」と警告している

RFC 7616 で登録されているのは 3 つ。それぞれに -sess 版がある。

algorithm ハッシュ長 位置づけ
MD5 128 bit RFC 2617 からの互換用。新規に選ばない
SHA-256 256 bit RFC 7616 で追加。実質これを選ぶ
SHA-512-256 256 bit SHA-512 を 256 bit に切り詰めたもの

algorithm を省略すると MD5 扱いになる。書き忘れが即ダウングレードになるので、必ず明示する。

-sess を付けると A1 の作り方が変わり、セッション最初の noncecnonce が焼き込まれる

A1 (通常) = username ":" realm ":" password
A1 (-sess) = H(username ":" realm ":" password) ":" nonce ":" cnonce

H(A1) が nonce ごとに変わるので、長期間同じ値を使い続けずに済む。ただし Apache の mod_auth_digestMD5-sess を正しく実装していないとドキュメントに明記されている。仕様にあることと使えることは別だと分かる例。

サーバーは複数のチャレンジを並べられる

Section titled “サーバーは複数のチャレンジを並べられる”

RFC 7616 は、サーバーが WWW-Authenticate複数返して好ましい順に並べることを認めている。クライアントは自分が対応する中で最も強いものを選ぶ。

WWW-Authenticate: Digest realm="Staging Area", qop="auth", algorithm=SHA-256, nonce="..."
WWW-Authenticate: Digest realm="Staging Area", qop="auth", algorithm=MD5, nonce="..."

これは同時にダウングレードの窓でもある。 中間者が SHA-256 の行を削れば、クライアントは MD5 を選ぶ。TLS がなければこの操作は自由にできる。

方式 経路に出る値 サーバーの保存 失効・ログアウト ステートフルか
Basic パスワード(Base64) ソルト付き低速ハッシュにできる 仕組みがない ステートレス
Digest ハッシュ(nonce 混ぜ) H(A1)(パスワード等価) 仕組みがない nonce と nc を持つ
Bearer トークン トークンのハッシュ 有効期限・失効リストで可能 トークンストア次第
Cookie セッション セッション ID セッションストア + パスワードは低速ハッシュ サーバー側で破棄できる セッションを持つ
mTLS 証明書(TLS 層) 公開鍵のみ 失効リスト / 短命証明書 ステートレス

TLS を前提にすると、Digest が Basic に勝つ点はほぼ消える。 経路が暗号化されていれば「パスワードを平文で送らない」利点は意味を失い、「サーバーにパスワード等価の値を置く」欠点だけが残る。

flowchart TB
    A["Digest を検討している"] --> B{"TLS を使えるか"}
    B -->|"使える"| C{"パスワードを<br/>Argon2 などで保存したいか"}
    C -->|"したい"| D["Basic + TLS<br/>または Cookie セッション"]
    C -->|"H(A1) 保存を受け入れる"| E{"それでも Digest を<br/>選ぶ理由があるか"}
    E -->|"既存機器との互換性"| F["Digest を使う"]
    E -->|"ない"| D
    B -->|"どうしても使えない"| G{"相手は自分で<br/>作ったクライアントか"}
    G -->|"はい"| H["アプリ層で署名する<br/>HMAC など"]
    G -->|"いいえ"| F

    style D fill:#1f5c33,color:#fff
    style F fill:#7a4a12,color:#fff

使ってよいケースと使ってはいけないケース

Section titled “使ってよいケースと使ってはいけないケース”

Digest は「壊れている」のではなく、選ぶ理由がほぼ残っていない方式。だから判断は「安全か」ではなく「Basic + TLS ではなく Digest にする理由があるか」で切る。

  • H(A1) の保存を受け入れられる。 つまりパスワード等価の値がデータベースに載ってよい
  • パスワードは使い捨てか、他サービスと共用していない。 流出時の被害を realm 内に閉じられる
  • algorithm=SHA-256 以上を、相手のクライアントが確実に扱える(後述のブラウザ事情)
  • nonce の状態を共有できる。 単一サーバー、または nonce に時刻を埋め込んで検証を無状態にしてある
  • ログアウトとパスワード再設定の UI が不要
  • TLS が使えないか、Basic が明確に拒否される事情がある

ひとつでも欠けたら Basic + TLS か Bearer にする。とくに 1 つ目を飲めないなら、そこで判断は終わる。

Digest 認証が成立する 6 つの条件。H(A1) の保存を許容できる、パスワードを共用していない、SHA-256 を相手が扱える、nonce の状態を共有できる、ログアウト UI が不要、Basic では済まない理由がある

判断の分岐はこの順に辿る。

flowchart TB
    S["Digest 認証を使いたい"] --> Q1{"H(A1) の保存を<br/>受け入れられるか"}
    Q1 -->|"受け入れられない"| NG1["使わない<br/>Basic + TLS / Bearer"]
    Q1 -->|"受け入れる"| Q2{"相手は SHA-256 の Digest を<br/>確実に扱えるか"}
    Q2 -->|"MD5 に落ちる"| NG2["使わない<br/>総当たりに耐えられない"]
    Q2 -->|"扱える"| Q3{"nonce の状態を<br/>共有できるか"}
    Q3 -->|"できない"| NG3["使わない<br/>複数台で落ちる"]
    Q3 -->|"できる"| Q4{"Basic + TLS では<br/>済まない理由があるか"}
    Q4 -->|"ない"| NG4["使わない<br/>交換が割に合わない"]
    Q4 -->|"既存機器の互換性"| OK1["使える<br/>組み込み機器 / 既存 API"]
    Q4 -->|"TLS を張れない経路"| OK2["使える<br/>ただし暫定策として"]

    style OK1 fill:#1f5c33,color:#fff
    style OK2 fill:#1f5c33,color:#fff
    style NG1 fill:#7a2222,color:#fff
    style NG2 fill:#7a2222,color:#fff
    style NG3 fill:#7a2222,color:#fff
    style NG4 fill:#7a2222,color:#fff
ケース 成立する理由 一緒にやること
既存の組み込み機器・ネットワーク機器 相手のファームウェアが Digest しか話さない。選択肢がない 管理ネットワークを分離し、パスワードを使い捨てにする
RFC 2617 時代の既存 API の維持 移行コストが見合わない。動いているものを壊さない TLS を必須にし、algorithm を明示する。移行計画を持つ
TLS を張れない閉域の暫定策 平文よりはよい。攻撃者が経路にいる前提を少しだけ緩められる 暫定であることを期限付きで明文化する
相互認証が本当に要る自前クライアント rspauth でサーバーの真正性を確認できる クライアント側の rspauth 検証を実装する(既製品はしない)

「なぜ組み込み機器では成立するのか」を分解する。 Digest の弱点は「サーバーの保存が弱い」「ログアウトできない」「総当たりに弱い」だが、機器の管理画面では利用者が 1 人か数人で、パスワードは機器固有の使い捨て、ログアウトの概念もない。弱点が刺さる前提そのものが存在しないため、互換性という 1 点だけで選択が正当化される。

ケース できないこと
新規に作る Web アプリの認証 パスワードを Argon2 で保存できない。ログアウトも 2 要素認証も作れない
パスワードを他サービスと共用する利用者がいる場面 H(A1) の流出が realm 外へ波及する(総当たりで元のパスワードが割れる)
複数台のサーバーで負荷分散する構成 nonce の状態を共有しないとリクエストごとにランダムに落ちる
TLS の代わりとして 本文は保護されない。auth-int を使っても実装がない。中間者はダウングレードできる
リクエスト本文の完全性を守りたい場面 qop=auth-int は主要実装が未対応。Apache も未実装
MD5 しか話せない相手 GPU で総当たりされる。ハッシュが速すぎる
一般利用者向けのログイン Basic と同じ問題(ログアウト不能、再設定 UI なし、2FA を挟めない)

主要サーバーで Digest を標準搭載しているのは Apache だけ。ただし実装は RFC 2617 準拠で、RFC 7616 の SHA-256 には対応していない。

httpd.conf
<Location "/private/">
AuthType Digest
AuthName "Staging Area"
AuthDigestDomain "/private/"
AuthDigestProvider file
AuthUserFile "/etc/httpd/.digest_pw"
Require valid-user
</Location>
パスワードファイルの作成
# htpasswd ではなく htdigest。realm を引数に取るのがポイント
htdigest -c /etc/httpd/.digest_pw "Staging Area" demo
# 中身は user:realm:MD5(user:realm:password) の3列
cat /etc/httpd/.digest_pw
# demo:Staging Area:20435eaf6289f145538bcd35f4fd3c51

この 3 列目がそのまま H(A1)。ファイルを見れば分かるとおり、ソルトもなく段数もない。htpasswd で作る Basic 用のファイル(htpasswd -B なら bcrypt)と比べると、保存の強度が段違いに低い。

AuthDigestDomain を書き忘れると、認証が通用する範囲がクライアントに伝わらず、余計な往復が増える。

nginx に Digest 認証のモジュールは同梱されていない。 ngx_http_auth_digest というサードパーティモジュールがあるが、RFC 2617 準拠で、作者自身が「アルファ扱いで、疑ってかかってほしい」と書いている。

サードパーティモジュールを組み込んだ場合
location /private/ {
auth_digest "Staging Area";
auth_digest_user_file /etc/nginx/.digest_pw;
}

nginx を使っているなら、Digest を入れるために再ビルドする前に「Basic + TLS で済まないか」を必ず確認する

digest_verify.py
import hashlib
import hmac
def H(s: str, alg: str = "sha256") -> str:
return hashlib.new(alg, s.encode()).hexdigest()
def verify(ha1: str, params: dict, method: str, alg: str = "sha256") -> bool:
"""ha1 は保存済みの H(username:realm:password)"""
ha2 = H(f"{method}:{params['uri']}", alg)
expected = H(
":".join([
ha1,
params["nonce"],
params["nc"],
params["cnonce"],
params["qop"],
ha2,
]),
alg,
)
# 文字列比較ではなく定数時間比較にする
return hmac.compare_digest(expected, params["response"])

自前実装で間違えやすい点。

  • noncenc の消費を記録しないとリプレイを防げない。 計算が合っていても、同じ (nonce, nc) の 2 回目は拒否する
  • nc は 16 進 8 桁の文字列としてそのまま連結する。 整数に変換して桁が変わると一致しない
  • uri はクライアントが送ってきた値を使う。 サーバー側で正規化した URI で計算すると、クライアントの計算と食い違う
  • algorithm を省略されたら MD5 として扱うのが仕様。ただし MD5 を受け付けるかどうかは設計判断
  • 比較は定数時間で。 == はタイミング攻撃の入り口になる

運用とセキュリティで押さえる点

Section titled “運用とセキュリティで押さえる点”

ブラウザの実装状況が揃っていない

Section titled “ブラウザの実装状況が揃っていない”

RFC 7616 の一番の価値は SHA-256 の追加だが、ブラウザの対応がまだ揃っていない

ブラウザ SHA-256 の Digest
Firefox 93 以降で対応(2021 年)
Chromium 実装が進行中(Intent to Ship が出ている)
WebKit / Safari 標準化ポジションの issue が未回答のまま(2023 年 6 月に提起)

相手がブラウザなら、SHA-256 が通らずに MD5 へ落ちる可能性を前提に設計する。 落ちて困るなら Digest を選んではいけない。自前のクライアントやツール(curl など)なら SHA-256 を指定できる。

中間者は WWW-Authenticate を書き換えられる。

  • SHA-256 の行を削って MD5 だけ残す → 総当たりが現実的になる
  • WWW-Authenticate: Basic に差し替える → クライアントはパスワードを平文で送る。Apache のドキュメントも「中間者はブラウザを Basic に降格させられる」と明記している

どちらも TLS があれば起きない。 つまり Digest の安全性は結局 TLS に依存する。

H(A1)ソルトなしの 1 回ハッシュなので、辞書攻撃・レインボーテーブルが効く。パスワードファイルが漏れた場合の耐性は、bcrypt や Argon2 と比べて桁違いに低い。

  • パスワードを十分に長いランダム文字列にする(人間が覚える前提を捨てる)
  • ファイルの権限を最小にし、ドキュメントルート外に置く
  • 漏洩時は realm ごと変えるH(A1) に realm が入るため、realm を変えれば既存の値は使えなくなる)

Basic と同じで、Authorization 付きリクエストへの応答は共有キャッシュに保存してはならないRFC 9111 §3.5)。プロキシ向けには 407 / Proxy-Authenticate / Proxy-Authorization を使い、qopnonce の扱いは同じ。

Basic とまったく同じで、標準の手段がない。 ブラウザは H(A1) の材料をメモリに保持し、閉じるまで送り続ける。stale=true を返しても再入力は求められないので、むしろログアウトから遠ざかる。

  • Digest の仕様は RFC 7616(RFC 2617 を廃止)。フレームワークは RFC 9110 §11。
  • response = H( H(A1) : nonce : nc : cnonce : qop : H(A2) )
  • A1 = username : realm : passwordA2 = method : request-uriauth のとき)。
  • -sess 版は A1 = H(username:realm:password) : nonce : cnonce と形が変わる。
  • qop=auth-int のときだけ A2本文のハッシュが入る。実装はほぼない。
  • nc は 16 進 8 桁固定00000001 から。桁を詰めると失敗する。
  • cnoncencRFC 7616 では必須qop もチャレンジ側で必須になった。
  • algorithm 省略時は MD5 書き忘れがダウングレードになる。
  • 登録アルゴリズムは MD5 / SHA-256 / SHA-512-256 と各 -sess 版。
  • stale=true は「nonce が古いだけ」の意味。再入力を求めさせない。
  • サーバーは H(A1) を保存しなければならず、これはパスワード等価。 Argon2 は使えない。
  • realmH(A1) に入る。realm を変えると全パスワードが無効になる。
  • rspauthAuthentication-Info)でサーバー側の真正性を確認できる。Basic にはない。
  • 先回り送信ができない。 nonce が必要なので最初の 401 が必ず入る。
  • Digest はステートフル。 nonce と nc の消費をサーバーが覚える必要がある。
  • TLS があるなら Basic + TLS のほうが総合的に安全。 頻出の判断ポイント。
  • nginx は標準で Digest に対応していない。Apache の実装は RFC 2617 止まり。
Digest 認証の response はどう計算するか。

A1 = username:realm:passwordA2 = method:request-uri を作り、それぞれをハッシュ化する。そのうえで H(A1):nonce:nc:cnonce:qop:H(A2) を連結してもう一度ハッシュ化した値が response

nonce と cnonce はそれぞれ何のためにあるか。

nonce はサーバーが発行する使い捨ての値で、盗んだ response の再利用を防ぐ。cnonce はクライアントが生成する使い捨ての値で、サーバーだけに計算の材料を選ばせないためにある。両方が入ることで、どちらか一方が値を仕込んでも攻撃が成立しない。

nc は何を数えていて、書式の決まりは。

同じ nonce でそのクライアントが送ったリクエスト数(今回を含む)。16 進 8 桁固定で、最初は 00000001。サーバー側はどの nc まで使われたかを覚えておき、同じ値の 2 回目をリプレイとして拒否する。

stale=true が返ってきたらクライアントは何をすべきか。

パスワードは合っていて nonce が期限切れなだけなので、利用者に再入力を求めず、新しい nonceresponse を計算し直して再送する。サーバー側で stale を付け忘れると、利用者には「正しいパスワードなのに弾かれた」ように見える。

Digest 認証は Basic 認証より安全か。

経路については明確に安全。パスワードそのものが流れず、リプレイもできない。しかしサーバー側の保存は Basic より弱い。検証に H(A1) が必要で、これはパスワード等価かつソルトなしの 1 回ハッシュなので、Argon2 や bcrypt が使えない。TLS がある前提では、Basic + TLS のほうが総合的に安全になる。

なぜ Digest ではパスワードをソルト付きハッシュで保存できないのか。

サーバーが response を検証するには、クライアントとまったく同じ H(A1) = H(username:realm:password) を計算できなければならない。ソルトを足したり段数を増やしたりすると値が変わって一致しなくなる。つまり保存できるのは H(A1) そのものだけで、それが漏れれば認証を突破できる。

realm を変更すると何が起きるか。

realmH(A1) の計算に入っているため、保存済みの全ユーザーの値が無効になる。パスワードを配り直す必要がある。裏を返せば、同じパスワードを使っている別 realm へ流出が波及しない利点でもある。

algorithm を省略すると何が使われるか。

MD5。RFC 7616 で SHA-256SHA-512-256 が追加されたが、省略時の既定は互換のため MD5 のまま。書き忘れがそのままダウングレードになるので必ず明示する。

-sess 付きのアルゴリズムは通常版と何が違うか。

A1 の作り方が変わる。通常版は username:realm:password だが、-sess 版は H(username:realm:password):nonce:cnonce になり、セッション最初の noncecnonce が焼き込まれる。同じ H(A1) を長期間使い回さずに済む。ただし主要実装の対応は乏しく、Apache の mod_auth_digestMD5-sess を正しく実装していないと明記している。

qop=auth と qop=auth-int の違いは。

authA2 = method:request-uri で、メソッドと URI だけを保護する。auth-intA2 = method:request-uri:H(entity-body) となり、リクエスト本文の完全性も保護する。ただし主要実装がほぼ対応しておらず、Apache も未実装。

Authentication-Info の rspauth は何を証明するか。

サーバーが H(A1) を知っていること、つまり本物のサーバーであることを証明する。計算は response とほぼ同じで、A2 のメソッド部分を空にする(A2 = ":" request-uri)。Basic にはこの相互認証の仕組みがない。ただしブラウザは rspauth を検証しない。

Digest 認証で先回り送信ができないのはなぜか。

response の計算にサーバーが発行した nonce が必要なため。したがって Basic と違い、最初の 401 を受け取るまでクレデンシャルを組み立てられず、必ず 2 往復になる。Authentication-Infonextnonce を使えば、2 回目以降の 401 は省ける。

Digest 認証を負荷分散構成で使うと何が問題になるか。

Digest はステートフルで、サーバーは発行した nonce と消費された nc を覚えておく必要がある。複数台でこの状態を共有しないと、リクエストが別のサーバーに当たった瞬間に「未知の nonce」として弾かれる。nonce に時刻を埋め込んで検証を無状態にする手はあるが、それだけではリプレイ検知ができない。