Sansan Tech Blog

Sansanのものづくりを支えるメンバーの技術やデザイン、プロダクトマネジメントの情報を発信

Goで2要素認証のリカバリーコードを実装するときに考えたこと


この記事はSansan Advent Calendar 2025 - Adventarの11日目の記事です🎄

はじめに

こんにちは。技術本部Platform Engineering Unitの都筑です。

Bill OneやContract Oneで利用されている、認証基盤の開発・運用を担当しています。
今回はGoで書かれた認証基盤に、2要素認証のリカバリーコードを実装した際に検討したセキュリティ要件や実装方法について共有します。

本題の前に

本記事で参照しているガイドラインやハッシュアルゴリズムなどに関する推奨事項は将来的に変更される可能性があります。
2025年現在の情報であることを考慮したうえで、参考にしていただければと思います。

2要素認証リカバリーコード実装の背景

Bill OneではAuth0を認証基盤として利用していましたが、金銭的コストの削減のため認証基盤を内製化し、2023年12月にリリースを完了しています。
その当時の話はTech Blogに書いているので、気になる方はこちらを参照ください。

インボイス管理サービス「Bill One」の認証を内製認証基盤に置き換えて認証基盤のコストを削減した話 - Sansan Tech Blog

Bill Oneが認証基盤へ移行する前は、Auth0の機能を利用して2要素認証のリカバリーコード機能を提供していました。

ここでいう2要素認証のリカバリーコードとは、TOTP方式の認証アプリで認証している端末を紛失するなどして、2要素認証でログインできなくなった際に、アカウントへのアクセスを復旧するために使用するコードのことを指します。

Amazon Cognitoにはこのリカバリーコードに相当する機能がなく、またBill Oneではリカバリーコードの利用頻度が高くなかったこともあり、認証基盤への移行時にはこの機能を落とす判断をしました。

ところが、認証基盤移行後に2要素認証でログインができなくなったユーザーからのお問い合わせが、少量ながら定常的に発生するようになりました。これを受け、Bill One側ではシステム管理者が2要素認証設定をリセットできる機能を追加実装し問題を解消しました。

一方、Contract OneでもBill Oneと同様にAuth0を認証基盤として利用しており、Bill One同様認証基盤への移行を実施することになりました。しかし、スケジュールや開発者リソースの制約から、Bill Oneのような管理者によるリセット機能を新たに実装することが難しい状況でした。そのため、認証基盤に2要素認証のリカバリーコード機能を実装する流れとなりました。

セキュリティ要件の検討

参考にしたドキュメント

まずは、一般的なリカバリーコードに関するセキュリティ要件を調査し、それを元に認証基盤でのセキュリティ要件を決めていきました。
主に参考にしたのは次のドキュメントです。

1. NIST Special Publication 800-63B

NIST SP 800-63Bではアカウントリカバリーの方法が4つに分類されており、今回実装するリカバリーコードは「Saved Recovery Codes」に該当します。
「4.2.1. Saved Recovery Codes」では次のような記載があります。

At enrollment, a CSP that supports this recovery option SHOULD issue a recovery code to the subscriber. The recovery code SHALL include at least 64 bits from an approved random bit generator. The saved recovery code may be presented as numeric or a printable ASCII representation (e.g., Base64) for manual entry or as a machine-readable optical label (e.g., QR code) that contains the recovery code. At any point following enrollment, the subscriber MAY request a replacement recovery code. The issuance of a replacement recovery code SHALL result in an account recovery notification, as described in Sec. 4.6.

Saved recovery codes are intended to be maintained offline (e.g., printed or written down) and stored securely by the subscriber for future use. The verification of saved recovery codes SHALL be subject to the throttling requirements in Sec. 3.2.2. Saved recovery codes SHALL be stored in the subscriber account in hashed form using an approved one-way function, as described in Sec. 3.1.1.2. Following the use of a saved recovery code, the CSP SHALL invalidate that recovery code and SHALL issue a new saved recovery code to the subscriber.

この記述から、次のように整理し認証基盤でのセキュリティ要件としました。

  • 生成時の要件
    • 承認された乱数ビット生成器で生成し64bit以上としなければならない
    • 手入力のために数値形式または印字可能なASCII文字列としてもよい
  • 保存時の要件
    • 承認済みの一方向関数を用いてハッシュ化して保存しなければならない
  • 検証時の要件
    • リカバリコードの検証時にスロットリングを実装しなければならない
    • リカバリコードを再発行した際にはユーザーに通知をしなければならない
    • 使用後はリカバリコードを無効化し、新しいリカバリコードを発行しなければならない

実装方針

認証基盤の開発言語は Go であるため、Go での実装において選択したことや、コードサンプルから具体的に内容を共有できればと思います。
ここで紹介するコードはあくまで実装例であり、すべての実装が正しいことを保証するわけではありませんので、参考にする際には各々実装に不備がないかを確認・検証するようにしてください。

リカバリーコード生成方法

リカバリーコードでは次のような要件を満たす値を生成する必要があります。

  • 承認された乱数ビット生成器で生成し64bit以上としなければならない
  • 手入力のために数値形式または印字可能なASCII文字列としてもよい

これを満たすために、生成するリカバリーコードは「大文字アルファベット」と「数字」の組み合わせとし24桁としました。文字種は36種で24桁のため、エントロピーはおおよそ124bitとなり要件を満たします。

乱数生成に関しては「承認された乱数ビット生成器」でリカバリーコードを生成する必要があると記載があり、NISTに厳密に準拠するにはNIST SP 800-90で推奨される方法で乱数を生成する必要があります。

しかし、本質的な要件は攻撃者が予測できない十分な強度の乱数を生成できることであるため、今回は具体的な実装方針はOWASPのCryptographic Storage Cheat Sheetを参考にしました。
Cryptographic Storage - OWASP Cheat Sheet Series

Pseudo-Random Number Generators (PRNG) provide low-quality randomness that are much faster, and can be used for non-security related functionality (such as ordering results on a page, or randomising UI elements). However, they must not be used for anything security critical, as it is often possible for attackers to guess or predict the output.

Cryptographically Secure Pseudo-Random Number Generators (CSPRNG) are designed to produce a much higher quality of randomness (more strictly, a greater amount of entropy), making them safe to use for security-sensitive functionality.

疑似乱数生成器(PRNG)はセキュリティ上重要な用途には利用してはならず、暗号論的疑似乱数生成器(CSPRNG)を利用すべきという記述があります。
各言語ごとの推奨実装も記載があり、Goでいうと math/rand ではなく crypto/rand の利用が推奨されます。

また、特定の文字種に絞ったランダム文字列を生成したい場合、crypto/randText() の実装が参考になります。実際のコードは次のとおりです。
go/src/crypto/rand/text.go at 3f94f3d4b2f03a913de3f5a737bad793418e751f · golang/go · GitHub

package rand

const base32alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"

// Text returns a cryptographically random string using the standard RFC 4648 base32 alphabet
// for use when a secret string, token, password, or other text is needed.
// The result contains at least 128 bits of randomness, enough to prevent brute force
// guessing attacks and to make the likelihood of collisions vanishingly small.
// A future version may return longer texts as needed to maintain those properties.
func Text() string {
	// ⌈log₃₂ 2¹²⁸⌉ = 26 chars
	src := make([]byte, 26)
	Read(src)
	for i := range src {
		src[i] = base32alphabet[src[i]%32]
	}
	return string(src)
}

もしもbase32の文字種で許容できるようであれば直接 Text()を利用しても良いかもしれません。

リカバリーコード保存方法

リカバリーコードでは次のような要件を満たすよう保存する必要があります。

  • 承認済みの一方向関数を用いてハッシュ化して保存しなければならない

こちらも同様「承認済みの一方向関数」とあり、素直に従うとPBKDF2を利用することになります。
一方OWASPのPassword Storage Cheat Sheetでの推奨事項を見ると、次のような記載になります。

To sum up our recommendations:

  • Use Argon2id with a minimum configuration of 19 MiB of memory, an iteration count of 2, and 1 degree of parallelism.
  • If Argon2id is not available, use scrypt with a minimum CPU/memory cost parameter of (2^17), a minimum block size of 8 (1024 bytes), and a parallelization parameter of 1.
  • For legacy systems using bcrypt, use a work factor of 10 or more and with a password limit of 72 bytes.
  • If FIPS-140 compliance is required, use PBKDF2 with a work factor of 600,000 or more and set with an internal hash function of HMAC-SHA-256.
  • Consider using a pepper to provide additional defense in depth (though alone, it provides no additional secure characteristics).

こちらでは、Argon2id*1が第一候補として挙げられており、PBKDF2はFIPS-140承認の取得が必須な場合の選択肢として優先度が下げられています。今回はFIPS-140承認を取得する必要はないことと、後述する通りGoによるArgon2idの実装懸念が特にないことから、パスワードハッシュの業界標準としてOWASPが最優先で推奨しているArgon2idを採用しました。

これらの要件を元に、ある程度具体的な実装方針をみていきたいと思います。

ライブラリの選定

Argon2idの実装はGo公式の拡張ライブラリgolang.org/x/crypto/argon2に含まれるため、これを利用しました。

次のように簡単にhashを作成できます。

hash := argon2.IDKey(
    []byte("recovery code"),  // リカバリーコード
    salt, // salt
    2, // 繰り返し回数
    19*1024, // 使用メモリ量(KiB)
    1, // 並列スレッド数
    32 // タグサイズ
)

Argon2idを使用する際には、次のパラメータを設定する必要があります。

  • `m` (メモリコスト): 使用するメモリ量(KiB)
  • `t` (時間コスト): 繰り返し回数
  • `p` (並列度): 並列スレッド数

これらは自由に指定できますが、OWASPのPassword Storage Cheat Sheetではいくつかのパラメータの組み合わせが推奨されているので、これらの中から選定していくのが安全かと思います。

m=47104 (46 MiB), t=1, p=1 (Do not use with Argon2i)
m=19456 (19 MiB), t=2, p=1 (Do not use with 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

また、Argon2に関するRFCでもパラメータ選定に関する記述が存在します。
RFC 9106: Argon2 Memory-Hard Function for Password Hashing and Proof-of-Work Applications

We recommend the following procedure to select the type and the parameters for practical use of Argon2.

1. If a uniformly safe option that is not tailored to your application or hardware is acceptable, select Argon2id with t=1 iteration, p=4 lanes, m=2^(21) (2 GiB of RAM), 128-bit salt, and 256-bit tag size. This is the FIRST RECOMMENDED option.
2. If much less memory is available, a uniformly safe option is Argon2id with t=3 iterations, p=4 lanes, m=2^(16) (64 MiB of RAM), 128-bit salt, and 256-bit tag size. This is the SECOND RECOMMENDED option.
...

Password Storage Cheat Sheetでは記載がありませんでしたが、RFCでは第一推奨のパラメータとしてsaltは128bit(16byte)、またタグサイズは256bit(32byte)の例が挙げられています。

いくつか候補となるパラメータの値は上記の情報から絞りつつ、最終的にはパラメータを変えながら負荷試験を行うことで具体的な値を選定しました。負荷試験の実施方法などは主題からそれるため詳細を省きますが、アプリケーションやアーキテクチャの特性によって負荷状況やパフォーマンスへの影響は変わるため、負荷試験を行うことで現実的に利用できない組み合わせを事前に選択肢から除外できます。

Ory KratosではArgon2のパラメータをキャリブレートするコマンドが提供されているので、こういったものを利用してパラメータを選定しても良いかもしれません。
Argon2 parameters for secure password hashing and login | Ory

ハッシュの保存方法

ハッシュは単体で保存するのではなく、アルゴリズムやパラメータ、ソルトなどのメタデータも含めて一緒に保存するのが一般的です。
ハッシュの保存フォーマットはいくつかありますが、今回はPHC String Formatを利用しました。
PHC String Formatは古くからあるModular Crypt Formatを拡張したもので、事実上標準的なフォーマットとして広く受け入れられています。

例えば Auth0では Argon2でハッシュ化されたパスワードのインポートではPHC String Formatである必要があります。
一括ユーザーインポートのデータベーススキーマと例

実際のPHC String Formatの文字列は次のようなものになります。

$argon2id$v=19$m=19456,t=2,p=1$<base64-encoded salt>$<base64-encoded hash>

このような定義されたフォーマットを利用することで、将来的なアルゴリズム変更やパラメータチューニングに対応しやすくなりますし、ハッシュの可搬性も高くなることが期待できます。

当時は目ぼしいPHC String Formatへのエンコード・デコード用のライブラリがなかったため自前で実装しました。

リカバリーコードの検証

リカバリーコードの検証時には次のような要件を満たす必要があります。

  • リカバリコードの検証時にスロットリングを実装しなければならない
  • リカバリコードを再発行した際にはユーザーに通知をしなければならない
  • 使用後はリカバリコードを無効化し、新しいリカバリコードを発行しなければならない

上述の実装に関して書ける内容があまりなかったため、検証のロジックで気をつけるべき点について記載したいと思います。

ユーザーが入力した文字列のハッシュ値と、保存してあるハッシュ値を比較して同一かどうかを検証する場合、何も考えずに実装をすると入力内容によって処理時間に差が生じ、攻撃者にヒントを与えてしまう可能性があります。このように、処理時間の差から情報を推測する攻撃を「タイミング攻撃」と呼びます。

これを防ぐために、比較処理は入力値のハッシュ値と保存しているハッシュ値が一致するかどうかにかかわらず、計算時間が一定になるようにします。Goではcrypto/subtleパッケージでConstantTimeCompare()が提供されているため、ハッシュ値の比較にはこれを利用するのが無難です。

実際には、オンライン環境ではネットワークなどによる処理時間のばらつきがあることや、スロットリングによって総当たり攻撃の難易度が高くなることもあり、タイミング攻撃のリスクは相対的に低いと考えられます。
それでもコストをあまりかけず対策できるため、念の為実施しておくと良い、という位置づけで捉えています。

func verifyRecoveryCode(code, storedHash string) (bool, error) {
	salt, hash, m, t, p, err := parsePHCFormat(storedHash)
	if err != nil {
		return false, err
	}

	computedHash := argon2.IDKey(
		[]byte(code),
		salt,
		t,
		m,
		p,
		uint32(len(hash)),
	)

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

以上で、リカバリーコードに関する一連の実装を説明しました。

便利なツール

最後に、実装時に利用して便利だったツールを紹介して終わります。
自分で生成したハッシュが正しいのかを自身が実装したコードではない外部のツールで検証したい、テスト用のハッシュがサクッと欲しいなどの場合 Argon2 Hash Generator, Validator & Verifier のようなツールを使うと便利です。

注意としてはオンラインツールのため、当たり前ですが本番利用しているデータなどは入力せず、テスト利用や挙動の確認程度にしておきましょう。

また文中でも紹介しましたが、Ory KratosではArgon2のパラメータをキャリブレートするコマンドが提供されています。
Argon2 parameters for secure password hashing and login | Ory

まとめ

セキュリティ要件はNISTやOWASPのCheat Sheetなど既存のフレームワークをなるべく利用して整理しました。実装においては、基本的に重要なロジックは独自実装せず、一般的に広く使われているライブラリを利用しつつ、アプリケーションやアーキテクチャによって最適な値が変わるパラメータなどは値を変え検証して値を選定しました。

セキュリティの推奨事項は日々変わるので、都度最適な選択を模索しつつ継続的に見直しも実施し、継続して安全にサービスを提供できるように日々取り組んでいきます。

最後になりますが、この記事を読んで少しでもPlatform EUに興味を持たれた方は、ぜひこちらの記事もご覧ください。
jp.corp-sansan.com

*1:Argon2idは、2015年に開催されたパスワードハッシュ化競技 Password Hashing Competition (PHC) で優勝した「Argon2」ファミリーの一種として開発されたハッシュアルゴリズムです。メモリ重視のArgon2i と GPU耐性に強いArgon2dも存在し、それらを組み合わせたハイブリッド方式がArgon2idです。

© Sansan, Inc.