🔐

結局ユーザーパスワードはどう保存すればいいんだ?

2024/07/15に公開

はじめに

ユーザーパスワードの管理は、システム開発において最も重要なセキュリティ課題の一つです。
OAuthやAWS Cognitoなどの認証サービスを利用することで、パスワードの管理を外部に任せることもできますが、
プロジェクトによっては自前でパスワードを管理する場合もあります。
では自分たちでパスワードを管理する際に、どのように暗号化すればいいのでしょうか?
万が一パスワードが漏洩した場合ちゃんと対応できるのでしょうか?
本記事では、ユーザパスワードいくつの暗号化手法及びそのリスクについて解説したいと思います。

前提知識

まず暗号化の三兄弟 - Encode, Hash, Encrypt の違いを理解しておきましょう。

Encode

  • 単に文字列を別の形式に変換するだけ
  • キーがなくても元に戻せる
  • 例:Base64エンコード、URLエンコード

Hash

  • 一方向の変換
  • キーがないため元に戻せない
  • 例:MD5, SHA-256

Encrypt

  • キーを使って暗号化する
  • キーがあれば元に戻せる
  • 例:AES, RSA

シナリオ1 - Base64エンコードを使う

パスワードは平文で保存すべきではないので、エンコードされたバスワードを保存すれば良いでしょうか?

例えば、パスワード password をBase64エンコードすると cGFzc3dvcmQ= としてデータベースに保存されます。

base64

package main

import (
	"encoding/base64"
	"fmt"
)

func encodeBase64(input string) string {
	return base64.StdEncoding.EncodeToString([]byte(input))
}

func decodeBase64(input string) (string, error) {
	decoded, err := base64.StdEncoding.DecodeString(input)
	if err != nil {
		return "", err
	}
	return string(decoded), nil
}


func main() {
	password := "password"
	
	encodeStart := time.Now()
	encoded := encodeBase64(password)
	encodeElapsed := time.Since(encodeStart)
	
	fmt.Printf("Encoded: %s\n", encoded)
	fmt.Printf("Encode time: %.3f ms\n", float64(encodeElapsed.Nanoseconds()) / 1e6)
	
	decodeStart := time.Now()
	decoded, err := decodeBase64(encoded)
	decodeElapsed := time.Since(decodeStart)
	
	if err != nil {
		fmt.Printf("Error decoding: %v\n", err)
		return
	}
	
	fmt.Printf("Decoded: %s\n", decoded)
	fmt.Printf("Decode time: %.3f ms\n", float64(decodeElapsed.Nanoseconds()) / 1e6)
}

---
Encoded: cGFzc3dvcmQ=
Encode time: 0.001 ms
Decoded: password
Decode time: 0.000 ms

エンコードされた文字列は確か人の目で見ると意味不明ですが、実は安全ではありません。
特にBase64エンコードは、最後に === が付与されるため、Base64エンコードされた文字列を見ただけで元の文字列を推測できます。

じゃあBase32、Base16、UTF-7などあまり知られていないエンコード手法を使えばいいのでは?

それも間違いです。コーディングアルゴリズムはあまり知られていなくてもハッカーは簡単に解読できます。
しかもChatGPTなどのAIツールを使えば、何のエンコード手法を使っているか簡単に特定できます。

シナリオ2 - AES-256で暗号化する

ユーザのパスワードが暗号化されていれば、より安全に保存できるでしょうか?

例えば、パスワード password をKey mysecret でAES-256で暗号化すると tTtBvqCILRhZiQlTHVaVqgp7ndenT7Qs5vvAm7I+Vaa+MStt としてデータベースに保存されます。
データベースが漏洩された場合でもKeyがなければ解読は不可能なので、Base64エンコードよりも安全でしょう。

aes256

package main

import (
	"crypto/aes"
	"crypto/cipher"
	"crypto/rand"
	"crypto/sha256"
	"encoding/base64"
	"fmt"
	"io"
	"time"
)

func deriveKey(key string, keyLength int) []byte {
	hash := sha256.Sum256([]byte(key))
	return hash[:keyLength]
}

func encrypt(password, key string) (string, error) {
	plaintext := []byte(password)
	block, err := aes.NewCipher(deriveKey(key, 32))
	if err != nil {
		return "", err
	}

	gcm, err := cipher.NewGCM(block)
	if err != nil {
		return "", err
	}

	nonce := make([]byte, gcm.NonceSize())
	if _, err = io.ReadFull(rand.Reader, nonce); err != nil {
		return "", err
	}

	ciphertext := gcm.Seal(nonce, nonce, plaintext, nil)
	return base64.StdEncoding.EncodeToString(ciphertext), nil
}

func decrypt(encrypted, key string) (string, error) {
	ciphertext, err := base64.StdEncoding.DecodeString(encrypted)
	if err != nil {
		return "", err
	}

	block, err := aes.NewCipher(deriveKey(key, 32))
	if err != nil {
		return "", err
	}

	gcm, err := cipher.NewGCM(block)
	if err != nil {
		return "", err
	}

	nonceSize := gcm.NonceSize()
	if len(ciphertext) < nonceSize {
		return "", fmt.Errorf("ciphertext too short")
	}

	nonce, ciphertext := ciphertext[:nonceSize], ciphertext[nonceSize:]
	plaintext, err := gcm.Open(nil, nonce, ciphertext, nil)
	if err != nil {
		return "", err
	}

	return string(plaintext), nil
}

func main() {
	password := "password"
	key := "mysecret"

	encryptStart := time.Now()
	encrypted, err := encrypt(password, key)
	encryptElapsed := time.Since(encryptStart)
	if err != nil {
		fmt.Printf("Encryption error: %v\n", err)
		return
	}
	fmt.Printf("Encrypted: %s\n", encrypted)
	fmt.Printf("Encryption time: %.3f ms\n", float64(encryptElapsed.Nanoseconds())/1e6)

	decryptStart := time.Now()
	decrypted, err := decrypt(encrypted, key)
	decryptElapsed := time.Since(decryptStart)
	if err != nil {
		fmt.Printf("Decryption error: %v\n", err)
		return
	}
	fmt.Printf("Decrypted: %s\n", decrypted)
	fmt.Printf("Decryption time: %.3f ms\n", float64(decryptElapsed.Nanoseconds())/1e6)
}


---
Encrypted: tTtBvqCILRhZiQlTHVaVqgp7ndenT7Qs5vvAm7I+Vaa+MStt
Encryption time: 0.096 ms
Decrypted: password
Decryption time: 0.004 ms

ただし、ユーザがログインする場合、ユーザが入力したパスワードが暗号化されたパスワードと一致するかどうかを確認する必要があるため、 Keyをサーバーに配置する必要があります。サーバーがハッキングされた場合、Keyの漏洩も難しくないでしょう。
また、社内の従業員がKeyを知っている可能性もあり、ヒューマンエラーによる漏洩も考えられます。

結論として、Keyのセキュリティが保証されないため、パスワードを暗号化された方法で保存することはお勧めしません。

シナリオ3 - パスワードをSHA1でハッシュ化する

パスワードをSHA1でハッシュ化すれば不可逆になると聞いたので、これで保存すればいいでしょうか?

その通りです。SHA1はハッシュ関数の一つで、元の文字列からハッシュ値を生成することができます。
例えば、パスワード password をSHA1でハッシュ化すると 5baa61e4c9b93f3f0682250b6cf8331b7ee68fd8 としてデータベースに保存されます。
ユーザーが都度ログインする際に、入力されたパスワードをSHA1でハッシュ化し、データベースに保存されたハッシュ値と比較することで認証を行います。

sha1

package main

import (
	"crypto/sha1"
	"encoding/hex"
	"fmt"
	"time"
)

func hashPassword(password string) string {
	hasher := sha1.New()
	hasher.Write([]byte(password))
	hashBytes := hasher.Sum(nil)
	return hex.EncodeToString(hashBytes)
}

func verifyPassword(password, hashedPassword string) bool {
	return hashPassword(password) == hashedPassword
}

func main() {
	password := "password"

	hashStart := time.Now()
	hashedPassword := hashPassword(password)
	hashElapsed := time.Since(hashStart)

	fmt.Printf("Hashed password: %s\n", hashedPassword)
	fmt.Printf("Hashing time: %.3f ms\n", float64(hashElapsed.Nanoseconds())/1e6)

	verifyStart := time.Now()
	isValid := verifyPassword(password, hashedPassword)
	verifyElapsed := time.Since(verifyStart)

	fmt.Printf("Password verification result: %v\n", isValid)
	fmt.Printf("Verification time: %.3f ms\n", float64(verifyElapsed.Nanoseconds())/1e6)
}

---
Hashed password: 5baa61e4c9b93f3f0682250b6cf8331b7ee68fd8
Hashing time: 0.001 ms
Password verification result: true
Verification time: 0.000 ms

しかし、不可逆だと言うものの、ハッカーは辞書攻撃やレインボーテーブルを使ってSHA1ハッシュ値を解読できるので、SHA1でハッシュ化するだけではセキュリティが保証されません。
さらにMD5Hashing.netのようなオンラインツールもあるため、SHA1ハッシュ値を解読することは簡単です。
ユーザーに長いかつ複雑なパスワードを設定するように促すことで、ハッカーが辞書攻撃を行うのを難しくなりますが、なかなか現実的ではありません。

md5hashing

シナリオ4 - 塩(Salt)を入れてからSHA1でハッシュ化する

パスワードを長く複雑にすればOKだったら、自分でランダムな文字列を使ってユーザーパスワードに挿入してからSHA1でハッシュ化すればいいでしょうか?

答えはYesです。いわゆる塩(Salt)を使ってパスワードをハッシュ化することで、ハッカーが辞書攻撃やレインボーテーブルを使ってハッシュ値を解読するのを難しくすることができます。
例えば、ユーザーがパスワード password を登録する際に、10桁のランダムな文字列を生成し、パスワードの先頭に挿入してからSHA1でハッシュ化してデータベースに保存します。

salt

package main

import (
	"crypto/rand"
	"crypto/sha1"
	"encoding/base64"
	"encoding/hex"
	"fmt"
	"time"
)

func generateSalt(length int) (string, error) {
	bytes := make([]byte, length)
	_, err := rand.Read(bytes)
	if err != nil {
		return "", err
	}
	return base64.URLEncoding.EncodeToString(bytes)[:length], nil
}

func hashPassword(password, salt string) string {
	salted := salt + password
	hasher := sha1.New()
	hasher.Write([]byte(salted))
	return hex.EncodeToString(hasher.Sum(nil))
}

func storePassword(password string) (string, string, error) {
	salt, err := generateSalt(10)
	if err != nil {
		return "", "", err
	}
	hash := hashPassword(password, salt)
	return salt, hash, nil
}

func verifyPassword(password, salt, storedHash string) bool {
	return hashPassword(password, salt) == storedHash
}

func main() {
	password := "password"

	storeStart := time.Now()
	salt, hashedPassword, err := storePassword(password)
	storeElapsed := time.Since(storeStart)
	if err != nil {
		fmt.Printf("Error generating salt: %v\n", err)
		return
	}

	fmt.Printf("Original password: %s\n", password)
	fmt.Printf("Generated salt: %s\n", salt)
	fmt.Printf("Stored password: %s\n", password+salt)
	fmt.Printf("Salted and hashed password: %s\n", hashedPassword)
	fmt.Printf("Hashing time: %.3f ms\n", float64(storeElapsed.Nanoseconds())/1e6)

	verifyStart := time.Now()
	isValid := verifyPassword(password, salt, hashedPassword)
	verifyElapsed := time.Since(verifyStart)

	fmt.Printf("Password verification result: %v\n", isValid)
	fmt.Printf("Verification time: %.3f ms\n", float64(verifyElapsed.Nanoseconds())/1e6)
}

---
Original password: password
Generated salt: p79VS89uPL
Stored password: passwordp79VS89uPL
Salted and hashed password: 79f249aa5fd3d3498e1aab76933e1a20d230eddb
Hashing time: 0.076 ms
Password verification result: true
Verification time: 0.001 ms

塩(Salt)はデータベースに保存され、ユーザーがログインする際に、入力されたパスワードに塩を挿入してからSHA1でハッシュ化し、データベースに保存されたハッシュ値と比較することで認証を行います。
塩を入れたパスワードが十分長いため、ハッカーが辞書攻撃やレインボーテーブルを使ってハッシュ値を解読するのは難しくなります。

md5hashing-salt

しかし、万が一データベースが漏洩された場合、ハッカーは塩(Salt)も一緒に取得する可能性があるので、
理論上やろうとすればSHA1単体と同じく辞書攻撃やレインボーテーブルを使ってハッシュ値を解読できるが、
かなりの計算量が必要になるため、ある程度のセキュリティは保証されるでしょう。

シナリオ5 - Bcryptでハッシュ化する

やはりSHA1と塩(Salt)を使っても不安です。もっとセキュアな方法はないでしょうか?

上記のように、SHA1と塩(Salt)を使ってもハッカーがハッシュ値を解読する可能性があるため、SHA1の代わりにBcryptを使うこともあります。
Bcryptのメリットは何かというと、現在のコンピューターの性能は昔よりかなり向上しているため、SHA1やMD5などのハッシュ関数は短時間で解読できる可能性がありますが、
Bcryptは計算量がより多いため、ハッカーがハッシュ値を解読するのが難しくなります。

package main

import (
	"fmt"
	"time"

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

func hashPassword(password string) (string, error) {
	bytes, err := bcrypt.GenerateFromPassword([]byte(password), 12) // 12とはcostのことで、計算量を指定する
	return string(bytes), err
}

func verifyPassword(password, hash string) bool {
	err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password))
	return err == nil
}

func main() {
	password := "password"

	hashStart := time.Now()
	hashedPassword, err := hashPassword(password)
	hashElapsed := time.Since(hashStart)
	if err != nil {
		fmt.Printf("Error hashing password: %v\n", err)
		return
	}

	fmt.Printf("Original password: %s\n", password)
	fmt.Printf("Bcrypt hashed password: %s\n", hashedPassword)
	fmt.Printf("Hashing time: %.3f ms\n", float64(hashElapsed.Nanoseconds())/1e6)
	
	verifyStart := time.Now()
	isValid := verifyPassword(password, hashedPassword)
	verifyElapsed := time.Since(verifyStart)

	fmt.Printf("Password verification result: %v\n", isValid)
	fmt.Printf("Verification time: %.3f ms\n", float64(verifyElapsed.Nanoseconds())/1e6)
}

---
Original password: password
Bcrypt hashed password: $2a$12$5HeIfRHX64zkXaqPfbJC4uDXk1mF.KgHGutYJiVPaJWP0/50Ac9X2
Hashing time: 235.197 ms
Password verification result: true
Verification time: 205.253 ms

ユーザーログイン時にもSHA1と同じように、入力されたパスワードをBcryptでハッシュ化し、データベースに保存されたハッシュ値と比較することで認証を行います。
BcryptはSHA1より計算の時間が1000倍ぐらい遅いですが、まだ許容範囲内です。
SHA1と比べてもう一つのメリットとしては今後コンピューターの性能が向上しても、Bcryptは計算量を増やせば対応できるため、セキュリティが保証されるという点です。

シナリオ6 - Argon2でハッシュ化する

bcryptは依然として安全で信頼できるパスワードハッシュアルゴリズムです。
ただし下記制限や懸念点もあります。

  • メモリ使用量:bcryptはメモリをそれほど多く使用しないため、特殊なハードウェアを使用した攻撃に対してはArgon2やscryptほど強くない
  • 入力長の制限:bcryptは通常72バイトまでの入力しか受け付けできない

Argon2は、2015年にPassword Hashing Competitionで選ばれた最高のパスワードハッシュ関数です。
パスワードハッシュ関数としてのセキュリティ、パフォーマンス、および柔軟性のバランスを取ることを目指して設計されています。

argon2

package main

import (
	"crypto/rand"
	"crypto/subtle"
	"encoding/base64"
	"fmt"
	"strings"
	"time"

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

type params struct {
	memory      uint32
	iterations  uint32
	parallelism uint8
	saltLength  uint32
	keyLength   uint32
}

func generateRandomBytes(n uint32) ([]byte, error) {
	b := make([]byte, n)
	_, err := rand.Read(b)
	if err != nil {
		return nil, err
	}

	return b, nil
}

func hashPassword(password string) (encodedHash string, err error) {
	p := &params{
		memory:      64 * 1024,
		iterations:  3,
		parallelism: 2,
		saltLength:  16,
		keyLength:   32,
	}

	salt, err := generateRandomBytes(p.saltLength)
	if err != nil {
		return "", err
	}

	hash := argon2.IDKey([]byte(password), salt, p.iterations, p.memory, p.parallelism, p.keyLength)

	b64Salt := base64.RawStdEncoding.EncodeToString(salt)
	b64Hash := base64.RawStdEncoding.EncodeToString(hash)

	encodedHash = fmt.Sprintf("$argon2id$v=%d$m=%d,t=%d,p=%d$%s$%s",
		argon2.Version, p.memory, p.iterations, p.parallelism, b64Salt, b64Hash)

	return encodedHash, nil
}

func verifyPassword(password, encodedHash string) (match bool, err error) {
	p, salt, hash, err := decodeHash(encodedHash)
	if err != nil {
		return false, err
	}

	otherHash := argon2.IDKey([]byte(password), salt, p.iterations, p.memory, p.parallelism, p.keyLength)

	return (subtle.ConstantTimeCompare(hash, otherHash) == 1), nil
}

func decodeHash(encodedHash string) (p *params, salt, hash []byte, err error) {
	vals := strings.Split(encodedHash, "$")
	if len(vals) != 6 {
		return nil, nil, nil, fmt.Errorf("the encoded hash is not in the correct format")
	}

	var version int
	_, err = fmt.Sscanf(vals[2], "v=%d", &version)
	if err != nil {
		return nil, nil, nil, err
	}
	if version != argon2.Version {
		return nil, nil, nil, fmt.Errorf("incompatible version of argon2")
	}

	p = &params{}
	_, err = fmt.Sscanf(vals[3], "m=%d,t=%d,p=%d", &p.memory, &p.iterations, &p.parallelism)
	if err != nil {
		return nil, nil, nil, err
	}

	salt, err = base64.RawStdEncoding.DecodeString(vals[4])
	if err != nil {
		return nil, nil, nil, err
	}
	p.saltLength = uint32(len(salt))

	hash, err = base64.RawStdEncoding.DecodeString(vals[5])
	if err != nil {
		return nil, nil, nil, err
	}
	p.keyLength = uint32(len(hash))

	return p, salt, hash, nil
}

func main() {
	password := "password"

	hashStart := time.Now()
	hashedPassword, err := hashPassword(password)
	hashElapsed := time.Since(hashStart)
	if err != nil {
		fmt.Printf("Error hashing password: %v\n", err)
		return
	}

	fmt.Printf("Original password: %s\n", password)
	fmt.Printf("Argon2 hashed password: %s\n", hashedPassword)
	fmt.Printf("Hashing time: %.3f ms\n", float64(hashElapsed.Nanoseconds())/1e6)

	verifyStart := time.Now()
	match, err := verifyPassword(password, hashedPassword)
	verifyElapsed := time.Since(verifyStart)
	if err != nil {
		fmt.Printf("Error verifying password: %v\n", err)
		return
	}

	fmt.Printf("Password verification result: %v\n", match)
	fmt.Printf("Verification time: %.3f ms\n", float64(verifyElapsed.Nanoseconds())/1e6)
}

---
Original password: password
Argon2 hashed password: $argon2id$v=19$m=65536,t=3,p=2$DrPtZ6NVCNo8ybCIl6mp4w$PBD2edjRd1+VOtfN4a8SX2OVfnKv8P2K5CVMKp5cbW8
Hashing time: 92.305 ms
Password verification result: true
Verification time: 65.178 ms

まとめ

ユーザーパスワードの保存方法にはいくつかの選択肢がありますが、現時点で最もセキュアな方法はBcryptやArgon2を使うことをお勧めします。
しかし高度なセキュアな方法を使うほど計算量が増えるため、パフォーマンスにも影響が出ることがあります。
セキュリティーレベルや実装要件など総合的に考えて、適切な方法を選択することが重要でしょう。

latest

Discussion