暗号化とハッシュ
Guren には、データを暗号化するユーティリティと、パスワードを安全にハッシュ化するユーティリティが入っています。
暗号化とハッシュ
Guren には、データを暗号化するユーティリティと、パスワードを安全にハッシュ化するユーティリティが入っています。
APP_KEY
Guren アプリケーションには必ず APP_KEY を設定します。base64 でエンコードした 32 バイトのシークレットで、暗号化、Cookie の署名、トークンの署名に使います。用途ごとのキーは HKDF でそれぞれ導出するので、APP_KEY が 1 つあればすべてのサブシステムを安全に保護できます。
キーの生成
# キーを生成して表示
bunx guren key:generate
# キーを生成して .env に直接書き込み
bunx guren key:generate --write
create-guren-app でプロジェクトの雛形を作ると、APP_KEY も自動で生成されます。
キーローテーション
暗号化済みのデータや有効なセッションを壊さずに APP_KEY をローテーションするには、次の手順を踏みます。
- 現在の
APP_KEYの値をAPP_PREVIOUS_KEYSに移す - 新しい
APP_KEYを生成する
# .env
APP_KEY=base64:<新しいキー>
APP_PREVIOUS_KEYS=base64:<古いキー>
古いキーが複数あるときはカンマで区切って並べます。Guren はまず現在のキーを使い、復号や署名の検証に失敗したら古いキーでも試します。
暗号化
機密データの暗号化には Encrypter クラスを使います。暗号方式は AES-256-GCM です。
セットアップ
32 バイトのキーを渡して Encrypter を作ります。
import { Encrypter, generateKey } from '@guren/core'
// 新しいキーを生成
const key = generateKey()
console.log(key) // base64:... (32バイトのキー)
// Encrypterを作成
const encrypter = new Encrypter({ key })
// キーローテーション対応
const rotatedEncrypter = new Encrypter({
key: newKey,
previousKeys: [oldKey],
})
データの暗号化
// 任意の値を暗号化(オブジェクトは自動的にJSONシリアライズされます)
const encrypted = encrypter.encrypt({ userId: 1, token: 'abc123' })
// シリアライズなしで文字列を暗号化
const encryptedString = encrypter.encryptString('secret message')
データの復号化
// 復号化(JSONは自動的にデシリアライズされます)
const data = encrypter.decrypt(encrypted)
// 戻り値: { userId: 1, token: 'abc123' }
// 文字列を復号化
const message = encrypter.decryptString(encryptedString)
// 戻り値: 'secret message'
キー管理
import { generateKey, Encrypter } from '@guren/core'
// 暗号学的に安全なキーを生成
const key = generateKey()
// 現在のキーを取得
const currentKey = encrypter.getKey()
暗号化キーは環境変数に入れ、安全に管理してください。
# .env
APP_KEY=base64:your-32-byte-key-here
エラーハンドリング
import { Encrypter } from '@guren/core'
try {
const decrypted = encrypter.decrypt(invalidPayload)
} catch (error) {
console.error('復号化に失敗しました:', (error as Error).message)
}
ハッシュ化
パスワードのハッシュ化には PasswordHasher を使います。実装は次の 3 つが同梱されています。
| クラス | アルゴリズム | ランタイム |
|---|---|---|
Hash(DefaultHasher のエイリアス) |
scrypt で書き込む(algorithm: 'argon2' なら Argon2id)。検証は保存済みハッシュの形式に合わせる |
両方 |
Argon2Hasher |
Bun.password。既定は Argon2id で、指定すれば bcrypt |
Bun のみ |
NodeHasher |
crypto.scrypt |
すべて |
特別な理由がなければ Hash を使ってください。AuthenticatableModel と ModelUserProvider が既定で使うハッシャーで、両方の形式を検証できるのはこれだけです。
Bun は node:crypto を実装しているので、NodeHasher もどちらのランタイムでも動きます。Bun 専用は Argon2Hasher だけです。
アプリケーションではハッシャーを直接構築せず、createApp({ auth: { hasher } }) で 1 回だけ選びます(認証を参照)。
Argon2Hasherは 2.23.0 までScryptHasherという名前でした。実際に書き込む形式は scrypt ではないため、名前を変えています。旧名も同じクラスを指しますが、非推奨です。
2 つの形式には互換性がありません。$scrypt$ のハッシュはどの環境でも検証できますが、Argon2id のハッシュは Bun.password がある環境でしか検証できません。そのため Hash は scrypt で書き込み、もう一方の形式のハッシュを見つけると needsRehash() で知らせます。
ハッシャーの作成
import { Hash } from '@guren/core'
// scrypt を書く。検証は保存されたプレフィックスに従い、scrypt・Argon2id・bcrypt を受け付ける
const hash = new Hash()
// Bun.password で Argon2id を書く。Bun.password のないランタイムでは例外になる
const argon2 = new Hash({ algorithm: 'argon2' })
アルゴリズムやコストパラメータを固定したい場合は、Argon2Hasher / NodeHasher を直接構築してください。詳しくはアルゴリズムオプションを参照してください。
パスワードのハッシュ化
const hashedPassword = await hash.hash('user-password')
// $scrypt$N=16384,r=8,p=1$...
AuthenticatableModel を継承したモデルでは、この処理が自動で行われます。create() に平文の password を渡すと、モデルがハッシュ化して passwordHash カラムに保存します。詳しくは認証を参照してください。
パスワードの検証
保存済みのハッシュが第1引数です。
const isValid = await hash.verify(hashedPassword, 'user-password')
この順序は Bun.password.verify(plain, hashed) や単体関数の verifyPassword(plain, hashed) と逆なので、呼び出すたびに確かめてください。引数はどちらも string のため、入れ替えてもコンパイルは通り、型エラーにはなりません。同梱のハッシャーは明らかな入れ替えを実行時に見つけ、正しい順序を示した TypeError を投げます。
たいていのアプリでは、これを直接呼ぶ必要はありません。AuthManager を設定していれば、セッションガードがユーザーの検索と照合をまとめて行います。アカウントが存在しないときにもダミーのハッシュ計算を走らせるので、応答時間からアカウントの有無を見分けられることもありません。
const user = await this.auth.guard('web').validate({ email, password })
if (!user) {
return this.json({ error: 'Invalid credentials' }, { status: 401 })
}
ガード名は明示してください。ベアラートークンは資格情報で認証する仕組みではないので、TokenGuard.validate() は例外を投げます。トークンだけを使う API でメールアドレスとパスワードからトークンを発行するなら、セッションガードか ModelUserProvider を明示的に取得してください。
再ハッシュが必要かチェック
if (hash.needsRehash(user.passwordHash)) {
await User.update({ id: user.id }, { password: plainPassword })
}
needsRehash() は、ハッシュに埋め込まれたパラメータとハッシャーの設定値を比べます。そのため、コストファクタを上げたあとは true を返します。Hash の場合は、自分では書き込まない形式のハッシュに対しても true を返します。セッションガードはログインに成功するたびにこれを呼び、必要ならその場でパスワードを再ハッシュします。
アルゴリズムオプション
Argon2(Bun のみ)
const hash = new Argon2Hasher({
algorithm: 'argon2id', // 'argon2i'、'argon2d'、'argon2id'(既定)
memoryCost: 65536, // メモリ使用量(KiB)
timeCost: 3, // 反復回数
})
Bcrypt
const hash = new Argon2Hasher({
algorithm: 'bcrypt',
cost: 12, // ログラウンド数
})
Scrypt(Node)
const hash = new NodeHasher({
cost: 16384, // CPU/メモリコスト(N)
memory: 8, // ブロックサイズ(r)
saltLength: 16, // ソルトのバイト数
keyLength: 64, // 出力のバイト数
})
同じ scrypt の実装は単体の関数としても使えます。こちらは PasswordHasher.verify() とは逆に、平文が第1引数です。
import { hashPassword, verifyPassword, needsRehash } from '@guren/core'
const stored = await hashPassword('user-password')
const ok = await verifyPassword('user-password', stored)
コントローラーでの使用
import { Controller, Hash } from '@guren/core'
export default class AuthController extends Controller {
private hash = new Hash()
async register() {
const { email, password } = await this.validateBody(RegisterSchema)
// AuthenticatableModel が password を passwordHash へハッシュ化する
const user = await User.create({ email, password })
return this.json({ user })
}
async login() {
const { email, password } = await this.validateBody(LoginSchema)
const user = await User.first({ email })
// 保存済みのハッシュが第1引数。verify(password, user.passwordHash) は
// 型としては通るが誤り
if (!user || !(await this.hash.verify(user.passwordHash, password))) {
return this.json({ error: '認証情報が無効です' }, { status: 401 })
}
if (this.hash.needsRehash(user.passwordHash)) {
await User.update({ id: user.id }, { password })
}
return this.json({ user })
}
}
セキュリティベストプラクティス
- 平文パスワードを保存しない: パスワードは保存する前に必ずハッシュ化します。
- 強度のある APP_KEY を使う:
bunx guren key:generate --writeで生成し、バージョン管理にはコミットしないでください。 - 独自の暗号化を作らない: 用意されているユーティリティを使います。
- キーを定期的にローテーションする:
APP_PREVIOUS_KEYSを使えば、ダウンタイムなしで入れ替えられます(キーローテーションを参照)。 - 形式の選択は
Hashに任せる: どの環境でも scrypt で書き込むので、ローカルの Bun で書いたカラムをデプロイ先でもそのまま検証できます。
テスト
import { describe, it, expect } from 'bun:test'
import { Encrypter, Hash, generateKey } from '@guren/core'
describe('暗号化', () => {
it('データを暗号化して復号化する', () => {
const encrypter = new Encrypter({ key: generateKey() })
const encrypted = encrypter.encrypt('secret')
const decrypted = encrypter.decrypt(encrypted)
expect(decrypted).toBe('secret')
})
})
describe('ハッシュ化', () => {
it('パスワードをハッシュ化して検証する', async () => {
const hash = new Hash()
const hashed = await hash.hash('password123')
const valid = await hash.verify(hashed, 'password123')
expect(valid).toBe(true)
})
it('無効なパスワードを拒否する', async () => {
const hash = new Hash()
const hashed = await hash.hash('password123')
const valid = await hash.verify(hashed, 'wrong-password')
expect(valid).toBe(false)
})
})