Skip to main content

Goで安全にパスワードをハッシュ化する

blog go password hashing

2023年12月5日

0 分で読めます

ユーザー認証情報とは、ユーザーの本人確認を行い、システムやアプリケーションへのアクセスを許可するために必要な情報です。通常、ユーザー名またはメールアドレスとパスワードが含まれます。ユーザー名はデータベースに平文で保存できますが、メールアドレスやパスワードなどの機密情報は平文で保存してはいけません。

この情報を保存しているデータベースに悪意ある攻撃者がアクセスした場合、情報を簡単に渡してしまうことは避けたいものです。攻撃者が手持ちのあらゆるリソースを使って執拗に試みても、情報にアクセスできないようにする必要があります。ここで重要になるのがハッシュ化です。

前回のJavaでのパスワードハッシュ化に関する記事に続き、今回はGoでのパスワードハッシュ化を取り上げます。まずは、パスワードのハッシュ化に関する概念とベストプラクティスをおさらいしましょう。

パスワードにはハッシュ化を使用する 

ハッシュとは、入力(ここではパスワード)を受け取り、固定長の文字列を生成する数学的関数です。出力は通常、英数字の組み合わせです。ハッシュ値またはハッシュコードと呼ばれるこの出力は、入力ごとに固有です。ハッシュ関数は一方向性を持つよう設計されているため、ハッシュ値から元の入力を逆算することは計算上、実行不可能です。安全なパスワードハッシュ化では、セキュリティを高めるため、パスワードを平文ではなくハッシュ値として保存します。

ハッシュ化と暗号化の違いは、ハッシュ化が一方向の関数であるのに対し、暗号化は暗号化された値から元の値を復元できる双方向の関数である点です。暗号化はメールアドレスなどの個人識別情報(PII)を含む機密データによく使われますが、パスワードにはハッシュ化が推奨されます。

しかし、ハッシュ化が一方向の関数なら、ユーザー認証情報はどうやって検証するのでしょうか。手順は簡単です。ユーザーが入力した平文パスワードのハッシュを作成し、データベースに保存してあるハッシュと比較します。ハッシュが一致すれば、パスワードは正しいということです。一致しなければ、認証情報は正しくありません。ハッシュ化の優れた点は、ユーザーの実際のパスワードを知る必要がないことです。

攻撃手法

先ほど、攻撃者が手持ちのあらゆるリソースを使って執拗に試みても、情報にアクセスできないようにする必要があると述べました。悪意ある攻撃者がハッシュからパスワードを割り出すために使える手法があります。

ハッカーは、侵害されたアカウントなど、別の情報源から入手したパスワードのリストを持っている場合があります。レインボーテーブルを使ってハッシュの解読を試みることができます。ハッシュが一致すれば、パスワードは解読されたことになります。また、さまざまなパスワードの組み合わせを試し、生成したパスワードのハッシュが元のパスワードのハッシュと一致するかを調べる総当たり攻撃が行われることもあります。

これらの手法には高速なリソースや高い計算能力が必要ですが、現在利用できる計算能力を考えると、SHA1ファミリーなど古いハッシュアルゴリズムに対しては有効であることが実証されています。

そのため、生成したハッシュを解読不可能にする、あるいは解読に数十年かかるほどにするには、新しいハッシュアルゴリズムとその他のベストプラクティスを採用することが非常に重要です。

ハッシュ化のベストプラクティス

最新のハッシュ化手法を使用する際のベストプラクティスを紹介します。

ソルトの付与

ソルト値とは、ハッシュの作成時にパスワードに追加する、ランダムに生成された値です(通常はランダムなバイトデータなどのバイナリデータ)。パスワードごとに固有のソルトを使用します。これにより、攻撃者はハッシュだけでなくソルトも解読する必要があるため、セキュリティがさらに強化されます。その結果、ハッシュの解読は2段階のプロセスとなり、より多くのリソースが必要になります。

ソルトの長さは、使用するハッシュアルゴリズムによって異なります。新しいアルゴリズムではソルトが自動生成されるため、指定する必要はありません。詳しくは、OWASP Password Storage Cheat Sheetをご覧ください。

ハッシュアルゴリズム

ハッシュアルゴリズムは、パスワードのハッシュ化において重要な役割を果たします。解読に強いアルゴリズムを選択することが不可欠です。最新のハッシュアルゴリズムでは、ワークファクターやハッシュの反復回数などの設定を調整でき、ハッシュをより解読しにくくできます。利用可能な計算能力を考慮し、より高い値を選ぶことをおすすめします。一般に、ワークファクターや反復回数が高いほど、ハッシュの解読は困難になります。

ベストプラクティスとして、システムのパフォーマンスに大きな影響を与えずに処理できる範囲で、ワークファクターまたは反復回数を可能な限り高く設定してください。

ハッシュアルゴリズムに関する留意点

ハッシュアルゴリズムを実装する際に考慮すべき点を紹介します。

ハッシュ衝突への対処

ハッシュ衝突とは、異なる2つの入力から同じハッシュ値が生成されることです。最新のハッシュアルゴリズムは衝突が起きる可能性を最小限に抑えるよう設計されていますが、発生する可能性は残っています。衝突のリスクを軽減するには、SHA-256やSHA-512のような、出力空間が広く衝突の確率を下げられる長いハッシュ値を使用することが推奨されます。

反復回数

反復回数は、パスワードのハッシュ化における重要な要素です。反復回数が多いほど、ハッシュの解読に必要なリソースが増え、攻撃に時間がかかります。ただし、反復回数が多すぎると認証時に正規ユーザーを待たせる可能性があるため、セキュリティとパフォーマンスのバランスを取ることが重要です。パフォーマンスに大きな影響を与えず、十分なセキュリティを確保できる値を使用することをおすすめします。

ハッシュアルゴリズムを定期的に更新する

技術の進歩とコンピューターの処理能力の向上に伴い、新しいハッシュアルゴリズムが開発される一方、古いアルゴリズムは廃止されます。古いアルゴリズムはこうした進歩に対応できなくなり、攻撃者にとって格好の標的となるためです。

Goでのパスワードハッシュ化

Goのcrypto packageには、argon2、scrypt、bcrypt、pbkdf2など、広く使われているアルゴリズム向けのハッシュ関数が組み込まれています。これらの関数は、パスワードのハッシュ化機能を実装するうえで非常に便利です。bcrypt packageには、平文のパスワードとハッシュ化されたパスワードを比較し、一致するかを確認するCompareHashAndPassword関数も用意されています。

Argon2

Argon2は、現在最も安全なハッシュアルゴリズムと考えられています(今後変わる可能性はあります)。Argon2には3つのバリアントがあります。GPUを使った解読攻撃への耐性を最大化するArgon2d、サイドチャネル攻撃への耐性を高めるよう最適化されたArgon2i、そして両者を組み合わせたArgon2idです。

OWASP Password Storage Cheat Sheetでは、パスワードの保存にハイブリッド型のArgon2idアルゴリズムを使用することを推奨しています。このチートシートでは、メモリサイズの最小値(m)、反復回数の最小値(t)、並列度(p)について、次の値を推奨しています。

  • m=47104(46 MiB)、t=1、p=1(Argon2iでは使用しないこと)

  • m=19456(19 MiB)、t=2、p=1(Argon2iでは使用しないこと)

  • m=12288(12 MiB)、t=3、p=1

  • m=9216(9 MiB)、t=4、p=1

  • m=7168(7 MiB)、t=5、p=1

Goでの実装

ここでは、平文のパスワードからハッシュを生成し、そのハッシュと照合するArgon2idの実装例を見ていきます。

セットアップ

まず、Argon2idによるハッシュ化に必要な設定パラメーターをすべて格納する構造体を作成します。

type Argon2idHash struct {
	// time represents the number of 
	// passed over the specified memory.
	time    uint32
	// cpu memory to be used.
	memory  uint32
	// threads for parallelism aspect
	// of the algorithm.
	threads uint8
	// keyLen of the generate hash key.
	keyLen  uint32
	// saltLen the length of the salt used.
	saltLen uint32
}

Argon2idHash構造体には、次のパラメーターが含まれています。time、memory(計算コスト)、threads(並列度)、keyLen、saltLenです。これらのパラメーターを使って、平文のパスワードからハッシュを生成します。

次に、指定された値で構造体を初期化するコンストラクター関数を作成します。

// NewArgon2idHash constructor function for 
// Argon2idHash.
func NewArgon2idHash(time, saltLen uint32, memory uint32, threads uint8, keyLen uint32) *Argon2idHash {
	return &Argon2idHash{
		time:    time,
		saltLen: saltLen,
		memory:  memory,
		threads: threads,
		keyLen:  keyLen,
	}
}

ソルトの生成

ソルトの生成など、ランダムな値を生成しやすくするため、randomSecret関数を作成しました。この関数は、ランダムなシークレット値の希望する長さを受け取り、rand.Read関数を使って生成します。

func randomSecret(length uint32) ([]byte, error) {
	secret := make([]byte, length)

	_, err := rand.Read(secret)
	if err != nil {
		return nil, err
	}

	return secret, nil
}

ハッシュの生成

次に、Argon2idHash構造体にGenerateHashハッシュ化メソッドを作成します。このメソッドでは、設定した値を使ってハッシュを生成できます。ランダムなソルトが指定されていない場合は、設定済みのソルトと同じ長さのものを生成します。その後、設定値をすべて使い、argon2.IDKey関数でハッシュを生成します。生成に成功したら、ハッシュとソルトのペアを返します。

// GenerateHash using the password and provided salt.
// If not salt value provided fallback to random value
// generated of a given length.
func (a *Argon2idHash) GenerateHash(password, salt []byte) (*HashSalt, error) {
	var err error
	// If salt is not provided generate a salt of
	// the configured salt length.
	if len(salt) == 0 {
		salt, err = randomSecret(a.saltLen)
	}
	if err != nil {
		return nil, err
	}
	// Generate hash
	hash := argon2.IDKey(password, salt, a.time, a.memory, a.threads, a.keyLen)
	// Return the generated hash and salt used for storage.
	return &HashSalt{Hash: hash, Salt: salt}, nil
}

パスワードの比較

パスワードを比較するため、Argon2idHash構造体にもう1つCompareメソッドを作成します。このメソッドには、保存済みのハッシュとソルト、および照合するパスワードを渡します。

まず、前の手順で作成したGenerateHash関数を使ってハッシュを生成します。この関数には、指定されたパスワードとソルトを入力します。

次に、bytes.Equal関数を使って、生成したハッシュと保存済みのハッシュを比較します。2つのハッシュが等しければ一致です。一致しない場合は、保存済みのハッシュが指定されたパスワードと一致していません。

// Compare generated hash with store hash.
func (a *Argon2idHash) Compare(hash, salt, password []byte) error {
	// Generate hash for comparison.
	hashSalt, err := a.GenerateHash(password, salt)
	if err != nil {
		return err
	}
	// Compare the generated hash with the stored hash.
	// If they don't match return error.
	if !bytes.Equal(hash, hashSalt.Hash) {
		return errors.New("hash doesn't match")
	}
	return nil
}

この例では、argon2.IDKey関数を使ってハッシュを生成しました。前述のとおり、Goで提供される他のcryptoパッケージにも、この例と同様に安全なパスワードハッシュを生成できる関数があります。

Scrypt

ハッシュアルゴリズムのリストで2番目に挙げられるのがScryptです。第一候補はArgon2idですが、利用できない場合はscryptを代替として使用してください。

Argon2idと同様に、OWASP Password Storage Cheat Sheetでは、最小CPU・メモリコストパラメーター(N)、ブロックサイズ(r)、並列度(p)について、次の値を推奨しています。

  • N = 2^17(128 MiB)、r = 8(1024バイト)、p = 1

  • N = 2^16(64 MiB)、r = 8(1024バイト)、p = 2

  • N = 2^15(32 MiB)、r = 8(1024バイト)、p = 3

  • N = 2^14(16 MiB)、r = 8(1024バイト)、p = 5

  • N = 2^13(8 MiB)、r = 8(1024バイト)、p = 10

scryptパッケージのGo package docsには、scryptを使ってハッシュキーを生成する方法の例があります。生成と比較の処理は、Argon2idHash構造体での実装とよく似たものになるでしょう。

Bcrypt

bcryptは最後の選択肢としてください。FIPS-140準拠が必要な場合は、次に取り上げるPBKDF2を使用します。OWASP Password Storage Cheat Sheetによると、ワークファクターは検証サーバーの性能が許す限り大きく設定し、最低でも10にする必要があります。

Goのbcryptパッケージには、ハッシュの生成と比較を行う関数が用意されています。そのため、ここでは実装例を紹介しません。例についてはGo package docsをご覧ください。

PBKDF2(FIPS 140準拠)

OWASP Password Storage Cheat Sheetによると、PBKDF2のワークファクターは反復回数で指定し、内部で使用するハッシュアルゴリズムに応じて設定する必要があります。

  • PBKDF2-HMAC-SHA1:1,300,000回

  • PBKDF2-HMAC-SHA256:600,000回

  • PBKDF2-HMAC-SHA512:210,000回

ハッシュ化の例

ここで挙げたアルゴリズムのうちPBKDF2だけがFIPS 140に準拠しているため、Goでパスワードをハッシュ化する方法を見ていきます。それでは始めましょう。

平文のパスワードからハッシュを生成するために使用する、pbkdf2パッケージのKey関数を以下に示します。

func Key(password, salt []byte, iter, keyLen int, h func() hash.Hash) []byte

ハッシュ化を行うには、パスワードとソルトをバイトスライスとして用意し、反復回数と生成するキーまたはハッシュの長さを指定する必要があります。また、ハッシュ関数も必要です。ここでは、パスワードはハッシュ化する平文のパスワードを指し、ソルトにはランダムに生成した値を使用します。反復回数とキーの長さは設定できます。ハッシュ関数にはsha3.New256を使用します。

セットアップ

// PBKDF2Hash used to generate hash
// from plain text password and also
// compare plain text password with
// stored hash.
type PBKDF2Hash struct {
	// itr the number of iterations.
	itr int
	// keyLen the length of the generated key.
	keyLen int
	// saltLen the length of the salt used.
	saltLen int
}

ハッシュの生成

// GenerateHash using the password and provided salt.
// If not salt value provided fallback to random value
// generated of a given length.
func (p *PBKDF2Hash) GenerateHash(password, salt []byte) (*HashSalt, error) {
	var err error
	// If salt is not provided generate a salt of
	// the configured salt length.
	if len(salt) == 0 {
		salt, err = randomSecret(p.saltLen)
	}
	if err != nil {
		return nil, err
	}
	// Generate hash using pbkdf2 exported key method.
	hash := pbkdf2.Key(password, salt, p.itr, p.keyLen, sha3.New256)
	// Return the generated hash and salt used for storage.
	return &HashSalt{Hash: hash, Salt: salt}, nil
}

パスワードの比較

// Compare generated hash with store hash.
func (p *PBKDF2Hash) Compare(hash, salt, password []byte) error {
	// Generate hash for comparison.
	hashSalt, err := p.GenerateHash(password, salt)
	if err != nil {
		return err
	}
	// Compare the generated hash with the stored hash.
	// If they don't match return error.
	if !bytes.Equal(hash, hashSalt.Hash) {
		return errors.New("hash doesn't match")
	}
	return nil
}

これはArgon2idHashの実装と非常によく似ています。そのため、コード例全体を変更すれば、代わりにPBKDF2Hashを使用できます。

コード全体

それでは、すべてをまとめてみましょう。

プロジェクト構成

./hashing
|_main.go
|_argon2id.go
|_go.mod
|_go.sum

argon2id.goファイル:

package main

import (
	"bytes"
	"context"
	"errors"

	"golang.org/x/crypto/argon2"
)

// HashSalt struct used to store
// generated hash and salt used to
// generate the hash.
type HashSalt struct {
	Hash, Salt []byte
}

type Argon2idHash struct {
	// time represents the number of 
	// passed over the specified memory.
	time    uint32
	// cpu memory to be used.
	memory  uint32
	// threads for parallelism aspect
	// of the algorithm.
	threads uint8
	// keyLen of the generate hash key.
	keyLen  uint32
	// saltLen the length of the salt used.
	saltLen uint32
}

// NewArgon2idHash constructor function for 
// Argon2idHash.
func NewArgon2idHash(time, saltLen uint32, memory uint32, threads uint8, keyLen uint32) *Argon2idHash {
	return &Argon2idHash{
		time:    time,
		saltLen: saltLen,
		memory:  memory,
		threads: threads,
		keyLen:  keyLen,
	}
}

// GenerateHash using the password and provided salt.
// If not salt value provided fallback to random value
// generated of a given length.
func (a *Argon2idHash) GenerateHash(password, salt []byte) (*HashSalt, error) {
	var err error
	// If salt is not provided generate a salt of
	// the configured salt length.
	if len(salt) == 0 {
		salt, err = randomSecret(a.saltLen)
	}
	if err != nil {
		return nil, err
	}
	// Generate hash
	hash := argon2.IDKey(password, salt, a.time, a.memory, a.threads, a.keyLen)
	// Return the generated hash and salt used for storage.
	return &HashSalt{Hash: hash, Salt: salt}, nil
}

// Compare generated hash with store hash.
func (a *Argon2idHash) Compare(hash, salt, password []byte) error {
	// Generate hash for comparison.
	hashSalt, err := a.GenerateHash(password, salt)
	if err != nil {
		return err
	}
	// Compare the generated hash with the stored hash.
	// If they don't match return error.
	if !bytes.Equal(hash, hashSalt.Hash) {
		return errors.New("hash doesn't match")
	}
	return nil
}

main.goファイル:

package main

import (
	"context"
	"crypto/rand"

	"fmt"
	"os"
)

func main() {
	password := []byte("super-secret-password")

	argon2IDHash := NewArgon2idHash(1, 32, 64*1024, 32, 256)

	hashSalt, err := argon2IDHash.GenerateHash(password, nil)
	if err != nil {
		fmt.Fprintln(os.Stderr, err)
		os.Exit(1)
	}

	fmt.Println(hashSalt.Hash)
	fmt.Println(hashSalt.Salt)

	err = argon2IDHash.Compare(hashSalt.Hash, hashSalt.Salt, password)
	if err != nil {
		fmt.Fprintln(os.Stderr, err)
		os.Exit(1)
	}
	fmt.Println("argon2IDHash Password and Hash match")
}

func randomSecret(length uint32) ([]byte, error) {
	secret := make([]byte, length)

	_, err := rand.Read(secret)
	if err != nil {
		return nil, err
	}

	return secret, nil
}

アルゴリズムは慎重に選びましょう

上記の例は、Goでパスワード保存用の安全なハッシュ化アルゴリズムを実装する方法を学ぶうえで、よい入門になります。すべてのハッシュ化アルゴリズムが同じように安全とは限らないため、ニーズやアルゴリズムの現在のセキュリティ状況に応じて、慎重に選ぶことが大切です。今日強力とされるアルゴリズムも、時間とともに変わります。定期的に見直すようにしましょう。

また、Snyk CodeのようなSASTツールを使えば、古くなったアルゴリズムを特定し、必要に応じた更新方法についてアドバイスを得られます。パスワードハッシュ化アルゴリズムを慎重に選び、最新情報を把握して、コーディングを楽しみましょう。

続きを読む

blog feature ai lilac
Blog

エージェント型修正で脆弱性バックログをプルリクエストに変える

Evo Remediation Agentがオープンプレビューで利用可能になり、Evo Agentic AppSecに依存関係の自動解決機能が加わりました。AppSecチームは継続的な修正キャンペーンを実施できるようになりました

AI Background min crop
Blog

AIが、あなたが受け入れてきたあらゆるリスクを一気に顕在化させる:AI Security Panel: Unfilteredから得た7つの洞察

AIによって攻撃はより安価かつ高速になり、組織がすでに抱えているリスクが露呈しています。4人のセキュリティリーダーが、機械の速度に対応する防御と迅速な修復が今不可欠である理由を語ります。

blog feature snyk container custom base recommendations
Blog

Evo Agentic AppSecのご紹介:AI開発のスピードに追随するセキュリティ

AIを活用した開発は、従来のセキュリティワークフローを上回るスピードで進んでいます。SnykのAgentic AppSecは、セキュリティエージェントが脆弱性を継続的に検出、修正、検証し、人が基準を定められるようにします。