#️⃣

ハッシュ関数を TypeScript で学ぶ

に公開

ハッシュ関数とは

データを要約(digest)したいことがあります。要約言っても「なんかサイズがでかい」とか「赤色の面積が多い画像」とかではありません。データから特定の、指紋のような値を計算したいのです。人間にとっての指紋は、どの人が持つ指紋なのかを特定できる役割があります。それと同じように、データにとっての指紋があると便利なのです。

ハッシュ関数は、あるデータを入力として固定長の値を出力する関数です。入力するデータを平文、その出力のことをハッシュ値と呼びます。ハッシュ値はビット列です。ハッシュ値のサイズは関数のアルゴリズムによって異なります。

例えば Hello World という文字列を SHA-256 というハッシュ関数でハッシュ値を計算すると次のような値になります(hexによる表現)。

a591a6d40bf420404a011733cfb7b190d62c65bf0bcda32b57b277d9ad9f146e

入力される平文とハッシュ関数が同じ場合、何度計算しても、誰が計算しても同じハッシュ値が得られます。
逆に入力される平文が1ビットでも異なる場合、全く異なるハッシュ値が得られます。次は Hello-World という文字列の SHA-256 ハッシュ値です。

df99966a707b8ed42c5d8cd1dc69a6e22517245be8b784b7a87705ac4c0835c5

半角スペースをハイフンに変更しただけなのに、全く異なるハッシュ値になったことがわかります。
このことから、ある2つのハッシュ値が異なれば、それらの平文は異なると判定できます。逆に、2つのハッシュ値が同じであれば、それらの平文も同じであるだろうと判定できます。

ハッシュ関数に求められる特徴として、ハッシュ値から元のデータが計算できないことが挙げられます(原像計算困難性)。SHA-256 のハッシュ値として d2a84f4b8b650937ec8f73cd8be2c74add5a911ba64df27458ed8229da804a26 を与えられたコンピューターが、アルゴリズムによって元の文字列(Hello World)を特定できないということです。

ただし、ハッシュ値を与えられたコンピューターが「以前にHello Worldのハッシュ値を計算したことがあり、その値を今も記憶している」場合は別です。計算ではなくその記憶から元のデータを特定されてしまいます。

他には、ハッシュ値が同一になるような異なる2つの平文を簡単に探せないこともハッシュ関数に求められる性質です(衝突耐性)。2つのハッシュ値の平文が異なるかどうかを判定するのに使いたいのだから、簡単にハッシュ値が衝突されては困るわけですね。

より具体的には、あるハッシュ値が与えられたとき、それに対応する平文を現実的な時間内に探せないような性質を弱衝突耐性といいます。
そして、同じハッシュ値となる異なる2つの平文を現実的に探せない性質を強衝突耐性といいます。
あるハッシュ値になるような平文を当てる確率よりも、同じハッシュ値になるような2つの平文を当てる確率の方が高いため、後者のほうが「強い」耐性が必要なのでしょう。ちょうど、「クラスに自分と同じ誕生日のクラスメイトがいる」確率よりも「クラスに誕生日が同じクラスメイトが2人(以上)いる」確率の方が高いのと同じですね(誕生日のパラドックス)。

ちなみに、ハッシュ値が衝突するような平文は普通に存在します。平文は任意のデータサイズを取り得るのに対し、出力されるハッシュ値のビット数は固定だからです。例えば SHA-256 は、入力が1ビットだろうが1TBだろうが必ずハッシュ値を256ビットに収めます。出力の空間より入力の空間のほうが大きいので、ハッシュ値が衝突する平文の組み合わせはいくらでもあります(鳩の巣原理)。人間が(或いはコンピューターが)それを狙って衝突させられない性質が大切なのですね。

ハッシュ関数の種類

MD5

128ビットのハッシュ値を生成するハッシュ関数です。

すでに強衝突耐性が失われました。よっぽど特殊な用途がない限り使う理由はないでしょう。

過去にはオンライン麻雀ゲームのじゃんたまが牌山のハッシュ値を MD5 で計算して提示していました。牌山の見えていない部分が対局の途中に入れ替えられていないことを示すためだそうです。現在は SHA-256 に変更されています。

用途としては牌山パターンに対して固定のハッシュ値が付与出来ればいいので MD5 のままでも良かったのではと思うのですが、どうなんでしょうかね。

SHA-1

160ビットのハッシュ値を生成するハッシュ関数です。

すでに強衝突耐性が失われました。

有名な事例は、2017年に Google が同じ SHA-1 ハッシュ値に計算される異なる2つの PDF ファイルの生成に成功したものです。

https://shattered.io/

意図的にハッシュ値を衝突させられることから、改竄検知などの暗号用途では SHA-1 を使ってはいけないということになりました。CRYPTRECの運用監視暗号リスト(互換維持のための継続利用は容認するが新規利用は非推奨な暗号技術のリスト)にも掲載されています。

ただ、まだまだ超身近に現役で SHA-1 を使っているソフトウェアがありますね。Git です。Git は .git ディレクトリに過去のファイル(git オブジェクト)を全て保存していますが、そのときのファイル名がファイルの内容の SHA-1 ハッシュ値になっています。

Gitは SHA-1 を改竄検知のような暗号目的ではなく、単にファイルの内容を区別して git オブジェクトのファイル名にするためだけに使っているのでセキュリティ的な問題はありません。

SHA-2

2025年現在でハッシュ関数を使うならとりあえず選んでおけばよいやつです。

SHA-2 は総称で、以下のアルゴリズムを含みます。

  • SHA-224
  • SHA-256
  • SHA-384
  • SHA-512
  • SHA-512/224
  • SHA-512/256

SHA-256, SHA-512 についてはその3桁の数字がハッシュ値のビット数を示します。

SHA-224 は SHA-256 で計算された結果を224ビットに切り詰めてハッシュ値とします。

SHA-384 は SHA-512 で計算された結果を384ビットに切り詰めてハッシュ値とします。

SHA-512/224, SHA-512/256 は SHA-512 で計算された結果をそれぞれ224ビット、256ビットに切り詰めてハッシュ値とします。

切り詰めて使用されるものは、切り詰めないアルゴリズムと出力が部分一致するわけではありません。例えば SHA-256 と SHA-224 で同じ`のハッシュ値を計算すると次のようにまったく異なります。

# SHA-256
df99966a707b8ed42c5d8cd1dc69a6e22517245be8b784b7a87705ac4c0835c5
# SHA-224
d55e3eac216f3292a9557ee783f8d54cb0a6abeca433d2f786a1e90b

SHA-256 は内部の計算を32ビット単位で行うのに対し、SHA512 は64ビット単位で行います。その違いから、64ビットCPUのマシンでは SHA-256 の計算よりも SHA512 の計算のほうが高速になるようです。SHA512/256 のようなハッシュ値を切り詰めるバージョンが用意されているのは、64ビット CPU による計算効率が理由なのでしょう。

TypeScript での使い方

TypeScript でハッシュ関数を使用するには、Web Crypto API か node:crypto モジュールを使います。

  • Web Crypto API: ブラウザ JavaScript のために標準化された API ですが、多くの JavaScript ランタイムが実装しており可搬性があります
  • node:crypto: Node.js 標準ライブラリのひとつで、他のランタイムでは動きません(と言いたいが Node.js がデファクトスタンダードなので後追い勢がそのまま実行できるように実装していたりする)

本記事ではどのランタイムでも通用する Web Crypto API の使い方を紹介します。

function bufferToHex(buffer: ArrayBuffer): string {
  return Array.from(new Uint8Array(buffer))
    .map((b) => b.toString(16).padStart(2, "0"))
    .join("");
}

const textEncoder = new TextEncoder();

const plaintext = textEncoder.encode("Hello World");

const hashedText = await crypto.subtle.digest("SHA-256", plaintext);

console.log("Hashed Text:", bufferToHex(hashedText));

上記のコードを実行すると、次のような出力が得られます。

Hashed Text: a591a6d40bf420404a011733cfb7b190d62c65bf0bcda32b57b277d9ad9f146e

crypto.subtle.digest 関数に平文であるバイナリデータを渡すと、対応するハッシュ値を計算してくれます。バイナリデータ(ArrayBuffer, Uint8Array など)であればよいので、画像や音声ファイルなどのハッシュ値計算も可能です。ただし、Web Crypto API のハッシュ関数はストリーム非対応のため、大きすぎるファイルのハッシュ計算ではメモリの消費量に注意が必要です。

Web Crypto API がサポートするハッシュ関数の種類は次の通りです。第1引数で指定します。

  • "SHA-1"
  • "SHA-256"
  • "SHA-384"
  • "SHA-512"

ハッシュ関数の用途

改竄検知

2つの平文がたった1ビット異なれば、それらのハッシュ値はまったく異なるのがハッシュ関数の性質です。それを応用すればデータの改竄を検知することができます。

あるソフトウェアの実行ファイルを受け渡すことを考えます。もし受け渡しの途中で悪意のある人間にファイルがすり替えられたら、マルウェアを実行させられてしまうかもしれません。ファイルのすり替えに備えて、そのファイルのハッシュ値を計算しておき、別で渡しておきます。ファイルの受信側も同じハッシュ関数でファイルのハッシュ値を計算し、送信者から事前に受け取っていたハッシュ値と比較することで完全に同じファイルを受け取れたかを判定できます。

送信者が受信者に実行ファイルを送信するフローです。送信者はハッシュ関数で実行ファイルのハッシュ値を計算し、それを受信者に安全に送信します。送信者は実行ファイルを受信者に送信しようとしますが、攻撃者によって通信を介入されます。攻撃者はマルウェア入り実行ファイルを受信者に送りつけます。受信者はハッシュ関数でマルウェア入り実行ファイルのハッシュ値を計算し、送信者から安全に受け取ったハッシュ値と照合し、不一致であることがわかります。

ただし、そのハッシュ値もネットワーク越しで渡されるのが普通ですから、ハッシュ値の受け渡し時にも改竄されないように気をつけなければなりません(気をつけて改竄されないなら暗号技術なんて要らないので、また別の仕組みが必要です)。

パスワードの保存

アカウント登録機能がある Web サービス等でログインパスワードをデータベースに保存するとき、ユーザーに入力された文字列のまま格納しておくのは危険です。仮にデータベースが漏洩した時に第三者が正規ユーザーのアカウントに成りすましてログインするのを許してしまいます(現代においてアカウントのパスワードを当てただけでログインできるなんてセキュリティレベルが緩すぎますが、主題ではないので置いておきましょう)。

また、リテラシーの低いユーザーはパスワードを使い回します。攻撃者は、ある Web サービスから漏洩したパスワードを使って別の Web サービスのログインを試みることもあります。平文保存のリスクは他サービスにも影響を与えるのですね。

代わりにパスワードから計算されたハッシュ値を保存しておき、ユーザーがログインしようとするたびに送信されるパスワードからハッシュ値を計算して比較します。

ただし、前述の通り事前に計算しておくことでハッシュ値から平文を逆引きして特定することができます。なぜなら P@ssw0rd という文字列の SHA-256 ハッシュ値は誰が何度計算しても、b03ddf3ca2...という値になるからです。過去に P@ssw0rd という文字列の SHA-256 を計算したことがある人がハッシュ値 b03ddf3ca2... を見れば P@ssw0rd というパスワードが登録されているんだなと判断できてしまいます。あらかじめパスワードとして取りうるすべての文字列のハッシュ値を計算しておけば、ハッシュ値から平文パスワードが特定可能になります。これを総当たり攻撃といいます。

総当たりしたハッシュ値と平文パスワードのペアを愚直に保存するのは巨大なストレージ容量が必要になります。すべては保存せずに、事前に計算したペアを効率的に保存する仕組みがレインボーテーブルです。レインボーテーブルでは、あるハッシュ値から別の平文候補を提示する還元関数を用意し、ハッシュ値と平文候補を繋いだチェーンを大量に用意しておきます。各チェーンの最初と最後さえ保存しておけば、そのチェーンにあるすべてのハッシュ値と平文候補は計算可能なのでストレージを節約できます。漏洩したハッシュ値から何度か還元関数とハッシュ関数を繰り返し計算して、あるチェーンの末尾に一致するものが見つかればそのチェーンの中に平文があると判断できます。

レインボーテーブルによる平文探索を避けるためには、ユーザが指定した値をそのままハッシュ関数に入力するのではなく、パスワードを登録するたびにランダム生成される値(salt)をくっつけた上でハッシュ値を計算します。ハッシュ値と salt をセットで保存しておき、パスワード検証時にも同様に salt をくっつけたパスワードのハッシュ値を保存されているハッシュ値と比較することで受信したパスワードが正しいか判定できます。

パスワード登録フローの図です。ユーザーがサービスにパスワードを送信します。サービスは乱数生成器でsaltを生成します。サービスはパスワードとsaltを結合した値をハッシュ関数に渡して生成されるハッシュ値をデータベースに保存します。また、salt自体もデータベースに保存します。
パスワード検証フローの図です。ユーザーがサービスにパスワードを送信します。サービスはデータベースからsaltとハッシュ値を取り出します。saltとパスワードを結合した値をハッシュ関数に渡して生成されたハッシュ値と、データベースから取り出されたハッシュ値を照合します。

salt がランダム生成されることで、同じパスワードから異なるハッシュ値を計算できます(salt を保存しておく手間が増えますが)。これによって、パスワード平文とハッシュ値のペアを事前に知っていても、その知識を活用してハッシュ値からパスワードを特定することはできません。

salt の保存場所はたびたび話題に上がります。salt はハッシュ値と同じデータベース・同じテーブルに格納しておけば十分です。salt とハッシュ値のペアが見つかってもハッシュ関数の原像計算困難性から元のパスワードは理論的には特定不可能です。また、salt の付加によって事前に用意されたレインボーテーブルは参照できなくなります。

ただし salt を付けるだけでは安心できないことがあります。パスワードに使用可能な文字種が少ない場合(例えば英数小文字のみ)、ハイスペックな GPU を搭載するマシンではシンプルな総当り攻撃を現実的な時間内で達成できます。そこで、パスワード登録時にパスワードのハッシュ値をさらにハッシュ関数に入力し、その出力をまたハッシュ関数に入力し…という作業を繰り返すストレッチングを行います(実際は単にハッシュ関数を繰り返すだけでなく、計算コストを増加させる色々な工夫があります)。ストレッチングの目的はハッシュ計算のコストをあえて増加させることです。これによって総当り攻撃にかかる時間を引き上げさせ、ハッシュ値と salt が漏洩してもパスワードの特定を困難にします。

salt 付与やストレッチングはパスワードを保存する際に必須の処理ですが、自前実装するには非常に面倒かつオレオレ実装によるセキュリティリスクを含みます。それらを一括で引き受けてくれるパスワード専用ハッシュアルゴリズムが用意されています。ここでは Argon2 を紹介します。他にも選択肢はありますが、2025年現在で新たにパスワード保存フローを実装する場合は Argon2 を選んでおけば安心です。

Argon2 はハッシュ計算時に時間的なコストやメモリコストを増加させてハッシュ計算を実行してくれるハッシュ関数です。Web Crypto API では提供されていないので、Node.js 専用の npm パッケージを導入する必要があります。

npm install argon2

使い方は至ってシンプルです。

import { hash, verify } from "argon2";

const password = "P@ssw0rd";

const hashedPassword = await hash(password);

console.log("Hashed Password:", hashedPassword);

const verified = await verify(hashedPassword, password);

console.log("Password Verified:", verified);

上記コード例を実行すると次のような出力を得ます。

Hashed Password: $argon2id$v=19$m=65536,t=3,p=4$Li8jvXZoMQ2nwIr/DyzshA$9QRGIDJ9w9egV8Nr1TmnH8QONYgEpZRq16mS87O6WOY
Password Verified: true

hash 関数にただ平文パスワードを渡すだけでハッシュ化完了です。第2引数のオプションでコストパラメーターを調整することも可能です。

Argon2 では salt を管理する必要はなく、hashedPassword の文字列の中に埋め込まれています。これによって、salt をどこに保存すべきかという議論はする必要がありません。単に hash 関数の戻り値をデータベースに格納しておくだけでよいのです。

パスワードを検証するときも、単にハッシュ値と平文パスワードを verify 関数に通せばよいです。

Argon2 の出力文字列にはコストに関するパラメーターも埋め込まれており、verify でパラメーターを再指定することはありません。これによって、仕様変更でコストパラメーターを調整することになっても、hash 関数のオプションを調整するだけで済みます。すでにデータベースに保存済みのハッシュ値はそのまま verify をパスできるというわけですね。

まとめ

ハッシュ関数は、データの指紋と言えるハッシュ値を計算するための関数です。同じ平文から必ず同じハッシュ値が計算され、改竄検知やパスワード保存などに利用されています。

現在の主流はsha2系ハッシュ関数です。Web Crypto API を使えば、TypeScript で簡単にハッシュ値を計算できます。

パスワード保存では、単にパスワードのハッシュ値を保存するだけでなく、salt やストレッチングといった工夫が必要です。Argon2 のようなパスワード専用ハッシュアルゴリズムを使うことで、これらの処理を簡単に実装できます。

それではよい暗号ライフを!

GitHubで編集を提案

Discussion