🤼‍♂️

パスワードをどこのレイヤーで暗号化するか論争

2022/06/16に公開
2

最近転職をしまして、新しい会社ではGoを主に使います。
なので、Goの勉強、DDDの勉強を兼ねて、ユーザー登録・ログイン機能を持つアプリケーションを作ってみました。

https://twitter.com/ino_aka_matty/status/1531990600723353602

昨今のアプリケーションでは、IDaaSに任せるのが主流かなと思いますが、今回はデモアプリなのでユーザー作成、ログインなど全て自分で実装しています。

実装している中で「パスワードをどこのレイヤーで暗号化・認証するか」という問題にぶつかりまして、それを自分なりに解決するまでの一連の流れを書き留めます。

主な技術スタックは以下の通りです。

  • Go v1.16
  • Gin v1.4.0
  • gorm v1.23.5

極力今回の思考の流れをそのまま書くように心がけますので、意見・感想・共感等大歓迎でございます~~
みんなで設計の沼に浸かりましょう。

cipherパッケージで暗号化しようとする

まずは「Go 暗号化」で検索をしまして、一番最初に見つかったcipherパッケージで暗号化しようと考えました。
そして、レイヤードアーキテクチャではどこのレイヤーで暗号化を行うのがいいのかといろいろ調べた結果、DDDで有名な松岡さんの質問箱にこんな投稿が。

https://peing.net/ja/q/9fd60e2f-b6a2-4f70-8e8e-ece8bf066bf4
なるほどなるほど。リポジトリレイヤーに閉じ込めるといいのか。
早速実装してみます。

user.go
package user

import (
	"time"
)

type User struct {
	UserName  string    `json:"id" gorm:"primaryKey; not null"`
	Mail      string    `json:"mail" gorm:"not null"`
	Password  string    `json:"password" gorm:"not null"`
	CreatedAt time.Time `json:"created_at" gorm:"not null"`
	UpdatedAt time.Time `json:"updated_at" gorm:"not null"`
}

func NewUser(userName string, mail string, password string) *User {
	return &User{
		UserName:  userName,
		Mail:      mail,
		Password:  password,
		CreatedAt: time.Now(),
		UpdatedAt: time.Now(),
	}
}
cipher.go
package helper

import (
	"crypto/aes"
	"crypto/cipher"
	"fmt"

	"hogehoge/bootcamp-template/backend/src/config"
)

type Cipher struct {
	keyText string
	block   cipher.Block
}

func NewCipher(cfg config.Config) Cipher {
	keyText := cfg.Cipher.KeyText
	block, err := aes.NewCipher([]byte(keyText))
	if err != nil {
		fmt.Printf("Error: NewCipher(%d bytes) = %s", len(keyText), err)
		panic(err)
	}

	return Cipher{
		keyText: keyText,
		block:   block,
	}
}

func (cipher *Cipher) Encrypt(plainText string) string {
	cipherText := make([]byte, len(plainText))
	cipher.block.Encrypt(cipherText, []byte(plainText))
	return string(cipherText)
}

func (cipher *Cipher) Decrypt(cipherText string) string {
	plainText := make([]byte, len(cipherText))
	cipher.block.Decrypt(plainText, []byte(cipherText))
	return string(plainText)
}
user_repository.go
type UserRepository struct {
	database *gorm.DB
	cipher   helper.Cipher
}

// ・・・

func (repo *UserRepository) Find(userName string) (*user.User, error) {
	var user *user.User
	result := repo.database.First(&user, userName)
	if errors.Is(result.Error, gorm.ErrRecordNotFound) {
		return nil, errors.New(fmt.Sprintf("404 Not Found: User Name '%s' is not found.", userName))
	}
	user.Password = repo.cipher.Decrypt(user.Password)
	return user, result.Error
}

func (repo *UserRepository) Create(user *user.User) (*user.User, error) {
	user.Password = repo.cipher.Encrypt(user.Password)
	result := repo.database.Create(user)
	return user, result.Error
}

cipherパッケージが使いやすいように、簡単にラップした構造体を作って、リポジトリレイヤーでInsertの直前に暗号化、Selectの直後に復号化しています。
こうすると、リポジトリレイヤーより上のレイヤーでは暗号化・復号化を意識しなくてよくなります。

よしよし、いい感じに出来たぞ。と走らせてみるとエラーが。
どうやらAESでの暗号化は16byteの固定長文字列しか出来ないらしいですね。

https://deeeet.com/writing/2015/11/10/go-crypto/

上記の記事ではAESと他の暗号化のモードを組み合わせることで、任意の長さの文字列を暗号化できるようになるらしいですが、「Goの暗号化ってそんなにいろいろやんの??」と不思議に思い、他の暗号化方法も調べてみることにしました。

bcryptでハッシュ化しようとする

ちょっと調べてみると、bcryptというパッケージがあることを知ります。

https://pkg.go.dev/golang.org/x/crypto/bcrypt
GenerateFromPasswordっていう関数もあるし、パスワードの暗号化これじゃん」

早速実装しようとしてみますが、問題が1つ。
bcryptではハッシュ化するので、復号化が出来ません。

なので、先程実装したリポジトリレイヤーで暗号化・復号化完結させよう大作戦が不可能になったわけです。

bcryptでは、

  • GenerateFromPassword でパスワードをハッシュ化
  • CompareHashAndPassword でハッシュ化したパスワードと平文のパスワードの比較
    ができます。

これらをどこのレイヤーに任せるかということで小一時間悩みました。

思考の流れとしては以下の通り。

引き続きリポジトリレイヤーで頑張る

「暗号化する」という処理はドメインで扱うことでは無いし、ユースケースでもないよなぁ...と思い、最初にリポジトリレイヤーで頑張る方法を取ってみました。
方法としては、引き続きInsertの直前で暗号化を行い、パスワード比較用のメソッドとしてIsValidPasswordを実装しました。

user_repository.go
func (repo *UserRepository) Create(user *user.User) (*user.User, error) {
	user.Password = bcrypt.GenerateFromPassword(user.Password, bcrypt.DefaultCost)
	result := repo.database.Create(user)
	return user, result.Error
}

func (repo *UserRepository) IsValidPassword(userName string, password string) bool {
	var user *user.User
	repo.database.First(&user, userName)

	err := bcrypt.CompareHashAndPassword(user.Password, []byte(password))
	return err != bcrypt.ErrMismatchedHashAndPassword
}

実装したあとに、リポジトリレイヤーにある関数を見てみるとこんな並び

  • Create
  • Find
  • FindAll
  • Update
  • Delete
  • IsValidPassword

いや... 明らかに気持ちが悪い…
ドメイン知識がリポジトリに漏れ出てる感がすごい…

この方法はやめました。

リポジトリレイヤーで暗号化、ユースケースレイヤーで比較

「比較」というのはユースケースなわけだから、ユースケース層でやればいいのでは?ということでやってみました。

しかし、これだと急にユースケースレイヤーにbcryptのパッケージが現れます。
しかもユースケース上では暗号化なんて一切していないのに、取り出したパスワードは暗号化パッケージで比較。

うーん。なんとも気持ちが悪い。
これもやめました。

ユースケースレイヤーで暗号化・比較

リポジトリでやるからダメなのか??じゃあどっちもユースケースか??という安易な考えで実行。

ここらへんから疲れてきてます。
実装してみて思うけども、やはり「暗号化する」ってのはユースケースじゃないだろう…

やめました。

そもそもなんでリポジトリで暗号化したんだっけ?

最初はcipherパッケージで暗号化をしようとしていたので、リポジトリレイヤーで暗号化・復号化がスッキリ出来ていましたが、bcryptの場合はそうはいかない。
そもそもリポジトリレイヤーで暗号化してたのって、暗号化処理を閉じ込めて、外部に意識させないためだよな??

ここでふと松岡さんの質問箱を読み返してみました。

エンティティが暗号化を意識する場合、例えば、ユーザーオブジェクトがパスワードという属性を持ち、この項目のみ暗号化した値をdbに保持するとします。この場合、エンティティとしては「パスワード」という値をStiringで保持するのではなく「暗号化済パスワード」という型を定義して保持させる方法もあります。

なるほど! エンティティで暗号化をやってしまうという手もあるのか!!
「パスワード」として扱うのではなくて、「暗号化済パスワード」として保持すればいいのか!!

エンティティで暗号化・比較

エンティティを作成するときに、暗号化をやってしまいます。

user.go
package user

import (
	"time"

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

type User struct {
	UserName          string    `json:"user_name" gorm:"primaryKey; not null"`
	Mail              string    `json:"mail" gorm:"not null"`
	EncryptedPassword string    `json:"encrypted_password" gorm:"not null"`
	CreatedAt         time.Time `json:"created_at" gorm:"not null"`
	UpdatedAt         time.Time `json:"updated_at" gorm:"not null"`
}

func NewUser(userName string, mail string, password string) *User {
	return &User{
		UserName:          userName,
		Mail:              mail,
		EncryptedPassword: encrypt(password),
		CreatedAt:         time.Now(),
		UpdatedAt:         time.Now(),
	}
}

func encrypt(plainText string) string {
	hash, err := bcrypt.GenerateFromPassword([]byte(plainText), bcrypt.DefaultCost)
	if err != nil {
		panic(err)
	}
	return string(hash)
}

func (user *User) IsValidPassword(password string) bool {
	err := bcrypt.CompareHashAndPassword([]byte(user.EncryptedPassword), []byte(password))
	return err != bcrypt.ErrMismatchedHashAndPassword
}

(本来はパスワードの値オブジェクトとかも作ったほうがいいんですけどね)

こうすると、今後Userを扱う場合は全てパスワードが暗号化されており、認証するときはUserの構造体のIsValidPassword で比較をすればOK。

しかも構造体で初期化した時点で暗号化されているので、仮にあとから構造体にアクセスされたり、ログに吐いてしまったりしても、平文では無い安心感があります。
(これを思ってから、今までの実装方法危ねぇなと思いました)

これにて一件落着。設計がカッチリハマると嬉しいもんですね。
是非是非ご意見ご感想お待ちしております~~

告知

という勉強などを、入社してから技術に慣れるまで自由にやらせてくれる優しい会社なので宣伝しておきます。

ご興味ある方是非どうぞ~~

https://graffer.jp/recruit

https://meety.net/matches/hrewHKQjjaAa

Discussion

KZRNMKZRNM

最終的に bcrypt.GenerateFromPassword を採用しているので正しいのですが、暗号化したパスワードを復号[1]可能にするのは非常に危険です。
DB情報とソースコードがセットで流出したら平文での流出と同義だからです。

セキュリティ的には復号不可能なハッシュ化で取り扱うのがベストですし、bcrypt.GenerateFromPassword はそれに基づいて設計されています。

「パスワードの復号」は「ふぐの踊り食い」くらい厳禁だと気をつけたほうが良いです。

脚注
  1. 復号化は誤用であるとする人が多いです ↩︎

10inoino10inoino

ご指摘ありがとうございます!

「パスワードの復号」は「ふぐの踊り食い」くらい厳禁だと気をつけたほうが良いです。

これすごいパワーワードですね… 一生忘れないと思いますw

実は社内レビューでも、ここは指摘して頂きまして、ぜひ今後気をつけて実装したいと思った所存ですm(_ _)m