Basic 認証や Digest 認証をゼロから実装する機会があったのでその仕組をメモしておく。 本記事の node はすべて v18 である。

Basic 認証と Digest 認証

Basic 認証

はじめに Basic 認証の仕組みにを解説する。

Basic 認証においてクライアントが初めて http リクエストを送ったときは、Status Code が401 Unauthorizedでヘッダーに

www-authenticate: Basic realm="secure"

が付与されたレスポンスがサーバーから帰ってくる。 ちなみに realm は認証領域を表す。

クライアントがユーザーとパスワードを user:pass のように入力したときは

Authorization: Basic dXNlcjpwYXNz

を付与してレスポンスを返す仕組みになっている。 このような動作は Basic 認証を用いたサイトに Chrome で接続し、デベロッパーツールで通信を監視することで確かめることが出来る。

次に、dXNlcjpwYXNzの計算方法について確認しておく。 結論から言うとdXNlcjpwYXNzuser:passを base64 でエンコードしたものである。 Bash では

$ echo -n "user:pass" | base64
dXNlcjpwYXNz

JavaScript では

> btoa("user:pass")
'dXNlcjpwYXNz'

のように計算出来る。

ただ、Base64 はハッシュ関数でもなんでもなくて文字列を単純な方法で変換しているだけなので、簡単に戻すことが出来る。 Bash では

echo -n "dXNlcjpwYXNz" | base64 -d
user:pass

JavaScript では

> atob("dXNlcjpwYXNz")
'user:pass'

のように計算出来る。 これは Basic 認証において通信経路で http のヘッダーを覗かれるとユーザー名もパスワードもすべて筒抜けになることを示している。 つまり Basic 認証を使う際は https などを用いて暗号化するなど対策が必要になる。

Digest 認証

次に Digest 認証について説明を行う。 Basic 認証の欠点である通信経路をユーザー名とパスワードがそのまま流れてしまうという欠点を補ったのが Digest 認証である。

Digest 認証においてクライアントがサーバーに http リクエストを送ったときは、Status Code が401 Unauthorizedでヘッダーに

www-authenticate: Digest realm="secure", nonce="9HYa-V1Mba3QOk6f", algorithm=MD5, qop="auth"

を付与したレスポンスがサーバーから帰ってくる。 realm は認証領域、nonce は認証情報を隠すための乱数、algorithm は用いるハッシュ関数のアルゴリズムを表している。

クライアントがユーザーとパスワードを user:pass のように入力したときは

authorization: Digest username="user", realm="secure", nonce="9HYa-V1Mba3QOk6f", uri="/", algorithm=MD5, response="f11fd3424129e2f27546ce0fc1fd995a", qop=auth, nc=00000002, cnonce="59f367b44815ac37"

を付与してレスポンスを返す仕組みになっている。 ここで、uri はコンテンツの URI、response はハッシュ値であり認証に用いられる値、nc は同じ nonce を用いてリクエストするたびに増えてく値、cnonce はクライアント側で作られる乱数である。 response はユーザー名とパスワードと nonce や cnonce などを用いて作られる。 この値が毎回異なるハッシュ値のため、ここからユーザー名とパスワードを導くことは不可能である。

次に、response を計算する方法について説明する。 wikipedia には response は

A1 = ユーザ名 ":" realm ":" パスワード
A2 = HTTPのメソッド ":" コンテンツのURI
response = MD5( MD5(A1) ":" nonce ":" nc ":" cnonce ":" qop ":" MD5(A2) )

で計算されると書いてある。

これを参考に Bash で response を計算すると、

$ HA1=`echo -n "user:secure:pass" | md5`
$ HA2=`echo -n "GET:/" | md5`
$ nonce=9HYa-V1Mba3QOk6f
$ nc=00000002
$ cnonce=59f367b44815ac37
$ qop=auth
$ echo -n "${HA1}:${nonce}:${nc}:${cnonce}:${qop}:${HA2}" | md5
f11fd3424129e2f27546ce0fc1fd995a

となる。

JavaScript では

const crypto = require("crypto");

const md5 = async (data) => {
  const md5 = crypto.createHash("md5");
  return md5.update(data, "binary").digest("hex");
};

(async () => {
  const username = "user";
  const password = "pass";
  const realm = "secure";
  const HA1 = await md5([username, realm, password].join(":"));

  const method = "GET";
  const uri = "/";
  const HA2 = await md5([method, uri].join(":"));

  const nonce = "9HYa-V1Mba3QOk6f";
  const nc = "00000002";
  const cnonce = "59f367b44815ac37";
  const qop = "auth";
  const response = await md5([HA1, nonce, nc, cnonce, qop, HA2].join(":"));

  console.log(response); // f11fd3424129e2f27546ce0fc1fd995a
})();

で計算できる。

まとめ

  • Digest 認証のハッシュ値の計算を Bash と JavaScript を用いて計算を行った。
  • Digest 認証はサーバー側で生成した nonce と nonce を使った回数である nc を状態を保持して管理しないと、http のリクエストを盗聴された際にその Header をコピーしてリクエストすれば認証を突破出来てしまうと思った。(クライアントからサーバーへの二回目のアクセスの際に nonce や nc を付与して送信しているが、この nonce がきちんとサーバーから生成されたものであり、この nonce を用いた nc 回目の通信が以前に行われていないことを確認する必要がある。)
  • Basic 認証はサーバー側で状態を保持しなくていい分、圧倒的に実装が楽だからよく利用されているんだなぁと思った。
  • Digest 認証はサーバー側で状態を保持して毎回検索するので、メモリや CPU のリソースを結構食うのかもしれない。
  • Digest 認証でも https にしなければ中間者攻撃に弱いという話があるが、勝手に Basic 認証に変えちゃう以外の攻撃方法ってあるのかな。サーバー側で nonce と nc を管理しとけば、ヘッダー情報取られてもリクエスト 1 回しか認証突破できないし、その攻撃範囲も method と uri が限られる。実は意外と Digest 認証って安全な気がするので、ブラウザでも Basic 認証と表示を変えてくれればいいのに。
  • 一つ懸念として、nonce と cnonce と response が同時に流れてくるのでこの情報を集めておいて、スパコンとか使って頑張って計算して HA1 を割り出すことは可能なんじゃないかな?HA1 さえ割り出してしまえばそれで認証突破は出来る気がする。cnonce を HA1 に含めてハッシュ化とかしたらいいのにって思ってしまった。なんか勘違いしてるのかな。

追記

Digest 認証において、リバースプロキシが状態を保持しているのか確認したかったので、nc が重複するリクエストを送りつける実験を行う。 今回は私が普段から使っているtraefikを用いて試験を行う。

まず traefik のドキュメントを確認しながら、http://localhostに traefik を立てて digest 認証を有効化する。 そして次のコードを書く。

const crypto = require("crypto");

const KD = async (
  username,
  password,
  realm,
  method,
  uri,
  nonce,
  nc,
  cnonce,
  qop
) => {
  const HA1 = await md5([username, realm, password].join(":"));
  const HA2 = await md5([method, uri].join(":"));
  const response = await md5([HA1, nonce, nc, cnonce, qop, HA2].join(":"));

  return response;
};

const parse = (credentials) => {
  if (credentials == null || credentials == undefined) return;
  let c = {};
  credentials
    .substr(7)
    .split(", ")
    .forEach((param) => {
      const [key, val] = param.split("=");
      c[key] = val.replace(/"/g, "");
    });
  return [c.realm, c.nonce, c.opaque, c.algorithm, c.qop];
};

(async () => {
  const username = "user";
  const password = "pass";
  const method = "GET";
  const uri = "/";
  // const nc = "00000002"; 二度目の実験ではこのコメントを外す
  const cnonce = "2b93895fec6ce68d";

  const url = "http://localhost/";
  const res1 = await fetch(url);
  const credentials = res1.headers.get("www-authenticate");
  console.log(res1.status);
  let [realm, nonce, opaque, algorithm, qop] = await parse(credentials);

  for (let i = 1; i < 10; i++) {
    const nc = ("00000000" + i).slice(-8); // 二度目の実験ではコメントアウト

    const response = await KD(
      username,
      password,
      realm,
      method,
      uri,
      nonce,
      nc,
      cnonce,
      qop
    );
    const options = {
      method: "GET",
      headers: {
        Authorization:
          "Digest " +
          'username="' +
          username +
          '", realm="' +
          realm +
          '", nonce="' +
          nonce +
          '", uri="' +
          uri +
          '", algorithm=' +
          algorithm +
          ', response="' +
          response +
          '", opaque="' +
          opaque +
          '", qop=' +
          qop +
          ", nc=" +
          nc +
          ', cnonce="' +
          cnonce +
          '"',
      },
    };
    const res2 = await fetch(url, options);
    console.log(res2.status);
  }
})();

このコードを main.js で保存して実行すると、

$ node main.js
401
200
200
200
200
200
200
200
200
200

となり、最初以外全て認証突破している。 一方で、二度目の実験(nc を変化させない)を行うと、

$ node main.js
401
200
401
401
401
401
401
401
401
401

となる。 最初の 401 はサーバーが nonce を返す。 二度目の 200 は nc が初めて使われるので認証突破。 それ移行は nc がすでに使われているので認証を突破できない。 このことから traefik の Digest 認証は真面目に状態を保持していることがわかった。 さらに、サーバーで作った nonce を無視して、クライアント側で勝手に nonce を作って認証情報を送るなどの実験もしたが、それでは突破出来なかった。 また cnonce は毎回同じでも認証には関係なかった。

traefik の実装を見てみると、digest 認証の部分にはabbot/go-http-authが使われている。 このリポジトリを見ると内部でメモリの Map や Array などのデータ構造を用いて nc や nonce を管理している様子。