認証と認可 — パスワードからOAuth・パスキーまで
「あなたは誰か」と「あなたは何をしてよいか」は別の問題です。パスワードの保存、セッションとトークン、OAuthの4人の登場人物、そしてパスキーがなぜフィッシングに強いのかまでを、前提知識ゼロから一続きで積み上げます。
受付と、ドアの鍵は別のもの
オフィスビルに入るところを想像してください。まず受付で身分証を見せ、「たしかにこの人だ」と確認される。次に入館証を渡され、その入館証で開くドアと開かないドアが決まる。同じ建物にいても、経理室に入れる人と入れない人がいます。
この2段階が、そのまま技術用語になっています。
- 認証(Authentication, AuthN): あなたが誰であるかを確かめる。受付の身分証確認
- 認可(Authorization, AuthZ): あなたが何をしてよいかを決める。入館証で開くドアの範囲
名前が似ているので混ざりやすいのですが、混ぜると事故になります。「ログインさえしていれば、どのURLも叩ける」という設計は、受付を通った全員にマスターキーを渡しているのと同じです。実際の情報漏洩の相当数は、認証が破られたのではなく、認証を正しく通ったユーザーが、自分のものではないデータに手が届いたという認可側の穴から出ています。
以下、この2つを下から積み上げます。前提に使う暗号の道具(共通鍵・公開鍵・ハッシュ)は暗号を1からで扱いました。
HTTPは、あなたを覚えていない
前提を1つ。HTTPには「今この人と話している最中である」という状態がありません。リクエストは1本ずつ独立していて、サーバから見れば毎回が初対面です。HTTPの進化で見たとおり大量の接続をさばくには都合のいい性質ですが、認証にとっては困りものです。
だからログインは「一度確認して終わり」にできません。確認したという事実を持ち歩ける形にして、以後の全リクエストに添える必要があります。この形が何であるべきか——それがセッションIDであり、トークンであり、後半の主題です。
パスワード: 難しいのは照合ではなく保存
照合そのものは単純で、送られてきた文字列が登録された文字列と一致するかを見るだけです。本当の問題は、その「登録された文字列」をどう置いておくかにあります。
そのまま入れておけば照合はできますが、漏れた日にすべてが終わります。しかも被害は自社に留まりません。人はパスワードを使い回すので、漏れた表は他社のログイン画面でそのまま試されます(クレデンシャルスタッフィング)。
そこで、合鍵そのものではなく、その鍵でしか開かない錠前の型だけを預かるという発想に切り替えます。これがハッシュです。値は一瞬で作れるのに逆算はできない。照合時は、送られてきた値を同じ手順でハッシュしてから保存値と比べます。
ただしこれだけでは足りません。ハッシュは決まった関数なので、password123 のハッシュ値は世界中どこでも同じです。攻撃者は「よくあるパスワード → ハッシュ値」の巨大な対応表を先に作っておけばよく、漏れた表を引くだけで戻せてしまいます。
対策がソルトです。ユーザーごとにランダムな文字列を用意し、パスワードに混ぜてからハッシュする。ソルトはハッシュ値と一緒に保存して構いません(秘密にする必要はない)。これで同じパスワードの2人でも保存値が変わり、事前計算した表は無力になります。攻撃者はユーザー1人ずつやり直すしかありません。
もう一段あります。SHA-256のようなハッシュ関数は速いのが売りで、それは攻撃者にとっても速いということです。そこでパスワード用には、わざと遅く、わざとメモリを食う関数を使います。bcrypt、scrypt、Argon2 がそれで、いずれも「どれだけ重くするか」のコストパラメータを持ちます。bcrypt の cost が10なら内部で 回の鍵準備を回す、という具合です。ハードウェアが速くなったら数字を上げればいい、という形で将来に備えてあります。
import os, hmac, hashlib
def register(password: str):
salt = os.urandom(16) # ユーザーごとに違う塩
h = hashlib.scrypt(password.encode(), salt=salt,
n=2**15, r=8, p=1) # わざと重く、わざとメモリを食う
return salt, h # 両方保存する
def verify(password: str, salt: bytes, stored: bytes) -> bool:
h = hashlib.scrypt(password.encode(), salt=salt, n=2**15, r=8, p=1)
return hmac.compare_digest(h, stored) # 定数時間で比較する
最後の compare_digest にも理由があります。ふつうの == は先頭から1バイトずつ比べ、違いが出た時点で打ち切る。つまり一致した文字数が多いほど時間がかかる。差はマイクロ秒ですが、何万回も測れば統計的に見え、1バイトずつ正解を探れます。定数時間比較は必ず最後まで比べることでこの漏れを塞ぎます。
では、パスワード自体の強さはどう測るか。使える文字の種類を 、長さを とすると候補の総数は で、これを2の何乗かで表したものをエントロピーと呼びます。
つまり「1文字増えるごとに ビットずつ強くなる」という式です。小文字だけの26種なら1文字あたり約4.7ビット、記号まで含めた95種なら約6.6ビット。効いてくるのは、長さ は掛け算で効くのに、文字種 は対数の中にしか出てこないという非対称性です。記号を1つ混ぜて稼げるのはせいぜい数ビットですが、4文字伸ばせば20ビット以上増える。手数は なので、20ビットは100万倍です。「記号と数字を必ず1つずつ」より「長くする」が推奨に変わったのは、この式が理由です。
ただし が安全性の目安になるのは、攻撃者が手元でハッシュを総当たりできる場合だけです。オンライン攻撃(ログイン画面に片っ端から入力する形)は1回ごとにネットワークを往復するので毎秒数十回が限界で、効くのはエントロピーではなく回数の制限です。オフライン攻撃(表を盗んだ後に攻撃者のマシンの中だけで試す形)はレート制限が間に入らず、速度はハードウェアの上限そのもの。ここで初めて、エントロピーとコストパラメータが唯一の防波堤になります。
オンライン側で気をつけたいのは、制限の掛け方です。「5回失敗したらそのアカウントを凍結」は素直に見えますが、攻撃者はわざと失敗させて他人を締め出す嫌がらせに使えます。実務では、凍結するより失敗のたびに待ち時間を積み増す方式や、IPアドレス単位の制限を組み合わせる方式が選ばれます。そもそもオンライン攻撃は、後述する多要素認証を入れれば当たりを引いても通らなくなるので、レート制限だけで守り切ろうとしないのが要点です。
保存の周辺でよくやる事故も2つ挙げておきます。パスワードを検証前にログへ出すこと、長すぎる入力を黙って切り詰めることです。前者は平文の漏洩そのものですし、後者は利用者が思っているより短いパスワードで守られている状態を作ります。入力は切らずに受け取り、ハッシュに通す前の値はどこにも残さない——これだけで防げる種類の穴です。
なお という数え方は「 文字が完全にでたらめ」を前提にしています。人間が考える P@ssw0rd2026! は式の上では強く見えますが、攻撃者の辞書は単語のよくある変形(a→@、末尾に年号)を織り込み済みで、実効的な強さは式の値を大きく下回ります。推奨が「複雑さのルールを課す」から「十分な長さを求め、既知の漏洩パスワード一覧と突き合わせて弾く」に変わったのはこのためです。
コメント
コメントにはログインが必要です