Chienomi

パスワードにおける入力文字種の制限

プログラミング::beginners

先日、イオンスタイルのウェブサイトに登録したら、パスワードについて「半角英数記号」と書いてあったにも関わらず、記号を入れると登録できないという状況に直面した。

何が禁止となっているか書かれていないため、特定に非常に時間がかかり、腹立たしく思ったが、実のところこのようなことはまるで珍しくもなく、ウェブサービスにおいて安全なのは「そもそも記号を入れない(脆弱なパスワードを使う)」というのが現状である。

だが、それは(暗黙の文字数制限も相まって)確実性を狙って脆弱なパスワードを使うことを強く推進するものになるため、非常に危険であり、社会的に害悪である。

このような問題は、文字列、ハッシュ関数、そしてパスワードに対する知識の欠如から発生する。 事実、このような制約を「安全性のために」必要であると主張する人も見かける。

ここでは、この問題について正しい知識を持つ一助として解説を行う。

一般常識レベルの話

パスワードを保存するとき、

database["password"] = password

みたいにそのまま送られてきた値を保存するのは愚かな行為である。

この問題はいくつかあるが、最大は漏洩のリスクだ。 パスワードをそのまま保存していると、そのパスワードを使ってログインできるので漏洩時に直ちに被害が広がる。 また、パスワードを使いまわしている人は少なくないから、そのような人の被害はさらに広がることになる。

だから、パスワードはパスワードと分からない形で保存しなければならない。 そこで使われる(最も初歩的な)方法がハッシュ関数である。

パスワードがpasswordだとすると、当然そのままのパスワード文字列はpasswordである。 対して、MD5によって変換すると5f4dcc3b5aa765d61d8327deb882cf99となる。

ハッシュ関数

ハッシュ関数は、値Aから不可逆な値Bを導出するものである。

不可逆であるため、値Bから値Aを算出することはできない。

前述のようにpasswordのMD5ハッシュ値は5f4dcc3b5aa765d61d8327deb882cf99であるが、ここに改行が加わると286755fad04869ca523320acce0dc6a4という全く別の値になる。

このハッシュ値の計算は文字列に対してではなく、バイナリに対して行われている。 このため、対象は文字列でなくて良く、文字列であるならばそのバイナリ表現によって異なる結果となる。

% print password | md5sum
286755fad04869ca523320acce0dc6a4  -
% print password | iconv -f UTF-8 -t Shift_JIS | md5sum
286755fad04869ca523320acce0dc6a4  -
% print password | iconv -f UTF-8 -t UTF-16LE | md5sum
5ba5ce6599c5666f81d6e085f0ee6283  -

UTF-8もShift JISもASCII部はASCII互換のバイト表現であるために結果は同じだが、UTF-16LEはASCII部も2バイト単位となるため異なるバイト表現となり、結果が変わっている。

前述のとおりバイナリに対しても行える。

% md5sum < mpv-shot0001.png
3bea3f194f538c3a59a08629a8bc7514  -

このため、ファイルの同一性を確認するのにも使われている。

さて、ハッシュ値から元の値を算出するのは前述のとおり不可能である。 ところが、ハックにおいて重要なのは「同じハッシュ値を得る」ことであり、それ自体は別に不可能ではない。 MD5はその方法は既に確立されており、第三者が介在するセキュリティにおいては安全性がない。

ハッシュ関数は要約を目的としたもので、その用途は様々にある。その代表が高速な検索を可能にする連想配列であり、そのために連想配列が「ハッシュ」と呼ばれることも多い。 一方、パスワードで使うようなものは暗号学的ハッシュ関数と呼ばれる。

MD5よりも良い暗号学的ハッシュ関数がSHA1であるが、SHA1も衝突させる方法が見つかっており、現状で安全なのはSHA2関数である。 そして、SHA2関数よりも優れた代替は今のところない。

SHA2は出力長と強度の異なる6種類のバリエーションがある。 その中で特によく使われるのがSHA-256だ。

先程と同様にSHA256値を求めてみよう。

% print password | sha256sum
6b3a55e0261b0304143f805a24924d0c1c44524821305f31d9277843b8a10f4e  -
% print password | iconv -f UTF-8 -t UTF-16LE | sha256sum
cda6d6fa11c3fd6b982bf62f29eaf7ff63c485b0edf15dc4fc1ebdd091306691  -

SHA2の入出力

前提として、SHA2は入力がバイナリ、出力もバイナリである。

コンピュータにおいてバイナリ表現は数値とみなすことができ、バイナリ文字列と数値は可換の関係にある。 SHA2は入力数値を計算し、数値を出力する。

ただし、一般的には数値をそのまま吐くわけではなく、その数値を16進数で表現した文字列で出力することが多い。 実際、sha256sumは64文字の出力をしているが、64文字は512bitある。 SHA256は256bitであるから、文字列にした分長くなっている。

バイナリデータに含んではいけない「文字」などという概念がないということは容易に分かるだろう。 数値を数えていったら、ある数値はこの世に存在しない、などということにはならない。

ただ、SHA256は1300以上の実装が存在するそうだから、ひょっとしたら中には\000(NULL文字)を入れるとバグる実装も存在するかもしれない。 もしそんなことがあれば、答えは簡単だ。「そんな腐った実装を使うべきではない」。

そもそも生パスワードはプログラム上でも取り扱うべきではなく、最初から変換済みの値を取り扱うべきなので、次のようなプログラムになるだろう。

#!/bin/ruby
require 'digest/sha2'

password = Digest::SHA256.hexdigest STDIN.read

puts password

パスワードの文字どころか、パスワードの代わりにバイナリのキーファイルを使っても構わない。 シンプルで良いことだ。

HTTPフォームデータと文字エンコーディング

ただ、「入力は文字ではなくバイナリである」ということは、潜在的にバグになるリスクを抱えている。 これは、セキュリティリスクではなく、バグのリスクだ。

Rubyは入力を常にそのまま取り扱う。そのため、バイナリだろうがテキストだろうが、そのまま扱うことができるわけだ。

だが、HTTPフォームデータはバイナリ表現でそのまま送られるわけではなく、テキストとして送られてくる。 これは変換されるタイミングというものがあるため、必ずしもバイナリではなく意図したエンコーディングのテキストになっているという意味ではないが、基本的にはテキストであると考えていいだろう。 そして、現代においてはそれはUTF-8だと考えていい。

パスワードがASCII文字からなるという前提である場合、UTF-8はASCIIと互換であり、多くの場合問題ない。 ところが、内部文字エンコーディングにASCIIと互換性のないものを採用する言語においては、単純にハッシュ関数ライブラリにパスワードを渡したときに意図とは異なる結果になるかもしれない。 これは例えばPyhtonやJavaが持っているリスクだ。

ただ、このような場合においては、「内部文字エンコーディングを意識して、文字エンコーディングに注意を払え」以外の言いようはないだろう。そうするのがプログラマの義務だ。

これが、非ASCIIを許容するように設計すると、話はだいぶややこしくなる。 文字エンコーディングを常に気にしなければならないし、正規化も必要かもしれない。

このために、パスワードをASCII文字に限る、というのはまぁ現実的な対応である。 パスワードフィールドに非ASCII文字を入力するのが難しい環境も多いから、健全だと言える。 そのため、パスワードはASCIIに限る、という前提だとしても、それはフォーム側でバリデーションしなければならないとは限らない。パスワードフィールドに非ASCIIが入力されることは予期されないと考えることもできるからだ。

もちろん、この問題を踏まえても「使える(ASCIIの)記号を制限する」などという行為に対する妥当な理由など存在しない。 \046\066をなぜ区別する必要があるのか。

安全なパスワード関数

SHA2は優れた暗号学的ハッシュ関数であるが、推測の手がかりがあれば破れてしまう。

まず漏洩した状況だと考えて、単純にSHA256を介しただけであれば、片っ端からSHA256に変換していくことで総当りで破っていくことができる。これは一段階挟まっているだけで問題があまり解決していない。

そこで、パスワードにソルトと呼ばれる値を加える。 暗号学的ハッシュ関数は少しでも値が異なれば全く異なる値になるため、少し値を加えた上でハッシュ化すれば、推測は著しく困難になる。だからハッシュ関数にソルトを使おう、というのが初歩的な話だ。

しかし、ソルトも「よくあるもの」というのは知られているし、それだけで破れなくなるというものではない。 そこで、強固なパスワード関数はソルトの利用や、複数回のハッシュ関数などを含め、仮に漏洩したパスワードの完全な表があったとしても、容易に元の値を推測できないものになっている。

このようなパスワード関数で広く使われているのはBCryptであり、モダンで安全性が高いのはYescryptである。

Linuxであればmkpasswdで利用可能だ。

% mkpasswd -m help
Available methods:
yescrypt        Yescrypt
gost-yescrypt   GOST Yescrypt
scrypt          scrypt
bcrypt          bcrypt
bcrypt-a        bcrypt (obsolete $2a$ version)
sha512crypt     SHA-512
sha256crypt     SHA-256
sunmd5          SunMD5
md5crypt        MD5
bsdicrypt       BSDI extended DES-based crypt(3)
descrypt        standard 56 bit DES-based crypt(3)
nt              NT-Hash

sha256cryptでも、単純なsha256sumの結果とは異なるものになる。

% print password | sha256sum
6b3a55e0261b0304143f805a24924d0c1c44524821305f31d9277843b8a10f4e  -
% print password | mkpasswd -m sha256crypt --stdin
$5$O7H/chq6BiGx3Zwd$nCuy6njlpr7FlRYQo7DCAAY/8vZXS6XRI0bg4y0jM/9

これらを使うことはプログラマが工夫して独自の方法で推測困難なハッシュ値を算出するよりも安全である。 今や手動でソルトを加えるのは十分に安全ではない。

だが、BCryptを使うと、BCryptが先頭の72バイトのみを使うという制約により、長さの制限が加わる。 これは、別に長さがこれを超過しても結果は同じなので構わないのだが、超過した分は意味がない。

このような理由でパスワードの長さに制限をかけることは意味があるが、それ以外にはない。 8文字制限になっているようなサービスは、ごく単純にfoolである。

結論

ウェブアプリケーションにおいては

  • パスワードフィールドはASCII文字によって満たされ
  • フォームデータはUTF-8で送信される

という規約を以て、単純に安全なパスワード関数を通して暗号化されたパスワードのみを取り扱い、平文のパスワードは取り扱わない、で良い。

PythonやJavaのようなASCII非互換の内部文字エンコーディングを採用するものについては文字エンコーディングについての配慮を加える。

That’s all.

次のようなものはfoolである:

  • ASCIIの使える文字に制限がある
  • 極端な長さの制限がある

また、「パスワードを忘れた」ときにパスワードそのものを送ってくるサービスは、平文でパスワードを保存しているということなので、セキュリティに対する意識が全くない非常に危険なサービスであると考えることができる。 このようなものはお名前.comが該当するほか、ISPはかなりの割合でそうなっているようだ。