Claude CodeでTOTP二要素認証を実装する — RFC 6238のテストベクタで検証
TOTPの計算はRFC 6238の付録Bの数値で合わせ、使用済みコードの再利用拒否・試行回数制限・暗号化保存・リカバリコードまで、テストで固めてからClaude Codeに任せる手順です。
TOTP(時間ベースのワンタイムパスワード)は、実装が短いわりに外し方が多い機能です。コードが合うだけでは足りず、同じコードを二度通さないこと、失敗を数えること、シークレットを平文で置かないことまで揃って、はじめて二要素認証として働きます。Claude Codeに書かせるときも、何をもって正しいとするかを先にテストで決めておくと、出てきた実装をそのまま検証できます。
この記事では、Node.jsの標準モジュールだけで書いた最小実装を題材に、検証の軸になるRFC 6238の付録Bのテストベクタ、使用済みコードの拒否、試行回数制限、シークレットの暗号化保存、リカバリコードを順に組み立てます。コードは手元のNode.js 20で動かし、6件のテストがすべて通ることを確かめた版です。
TOTPの計算は何に合わせれば正しいと言えるか
TOTPは、共有シークレットと「現在のUnix時間を時間ステップで割った整数」からHOTPを計算するだけの仕組みです。RFC 6238の4.2節は、T = (現在のUnix時間 − T0) / Xと定義しています。既定値はX = 30秒、T0 = 0(Unixエポック)です。59秒ならT = 1、60秒ならT = 2になります。
正しさの基準は、RFCの付録Bにあるテスト値です。ここで引っかかりやすい点が2つあります。
- 表の桁数は8桁です。認証アプリが表示する6桁と見比べると、一致しないように見えます。8桁で計算して比べます
- 表の本文は共有シークレットをASCIIの「12345678901234567890」(20バイト)と書いていますが、SHA-256とSHA-512の行はその文字列を繰り返して、それぞれ32バイト、64バイトに伸ばしたシークレットで計算した値です。付録Aの参照実装のシードがそうなっています
後者を知らずに20バイトのまま全モードを試すと、SHA-1の行だけ合って残りが合わず、実装のバグだと思い込みます。Claude Codeにテストを書かせる場合も、この前提を指示に含めておく必要があります。
Claude Codeへの頼み方:先に計画、次にテスト
認証まわりは、いきなり書かせるより計画を見てから進めるほうが手戻りが減ります。Claude Codeにはファイルを読んで計画だけを出す計画モード(plan mode)があり、承認するまで編集しません。ステータスバーに ⏸ plan mode on と出ている間がその状態で、セッション中にShift+Tabを押して切り替えられます。
テストについては、コード付きの手順にある「Work with tests」の流れが使えます。未カバーの関数を洗い出し、テストの骨組みを作り、エッジケースを足し、最後に実行して失敗を直す、という順です。ドキュメントによれば、既存のテストの書き方に合わせて生成するので、期待する挙動を具体的に伝えるほど狙いどおりになります。
プロジェクトの規約はCLAUDE.mdに断片として置いておくと、セッションをまたいで効きます。たとえば次のような内容です。
## 認証コードの規約
- TOTP の期待値は RFC 6238 付録 B の表から転記する。実装の出力を写さない
- シークレットは平文でログ・DB・例外メッセージに出さない
- 比較は timingSafeEqual。文字列の === を使わない
- 検証の成功時に最後に使ったステップ番号を保存し、それ以下は拒否する
- 変更後は `node --test` を実行し、失敗を読んでから直すそのうえで、最初の依頼は実装でなく検証の定義にします。
RFC 6238 付録Bの表(SHA1/SHA256/SHA512、8桁、T0=0、X=30)を期待値にした
テストを先に書いて。SHA256は32バイト、SHA512は64バイトのシードで、
付録Aの参照実装に合わせること。実装はまだ書かない。テストが赤で並んだのを確認してから実装を書かせると、「合っているように見える」状態を避けられます。
計算部分とテストベクタの検証
HOTPの核は、8バイトのカウンターをHMACにかけ、末尾4ビットをオフセットにして31ビットを取り出し、10の桁数乗で割った余りを取る処理です。Node.jsでは node:crypto の createHmac だけで書けます。
export function hotp(secret, counter, digits = 6, algo = "sha1") {
const buf = Buffer.alloc(8);
buf.writeBigUInt64BE(BigInt(counter));
const h = createHmac(algo, secret).update(buf).digest();
const off = h[h.length - 1] & 0x0f;
const bin = h.readUInt32BE(off) & 0x7fffffff;
return String(bin % 10 ** digits).padStart(digits, "0");
}
export const stepOf = (unixSec, period = 30) => Math.floor(unixSec / period);カウンターを64ビットで扱っている点は、RFCの要件にも合っています。4.2節は、2038年を超える時間値を32ビット整数より大きく扱えることを必須としています。writeBigUInt64BE を使えば、この要件を意識せずに満たせます。
テストは、付録Bの6つの時刻 × 3つのモード、計18個の期待値を回します。
const S1 = Buffer.from("12345678901234567890");
const S256 = Buffer.from("12345678901234567890123456789012");
const S512 = Buffer.from(
"1234567890123456789012345678901234567890123456789012345678901234");
test("RFC 6238 Appendix B", () => {
for (const [t, a, b, c] of vec) {
assert.equal(totp(S1, t, { digits: 8, algo: "sha1" }), a);
assert.equal(totp(S256, t, { digits: 8, algo: "sha256" }), b);
assert.equal(totp(S512, t, { digits: 8, algo: "sha512" }), c);
}
});たとえば59秒のSHA-1は94287082、1234567890秒(2009-02-13 23:31:30 UTC)は89005924です。実装を手で動かすと、59秒の8桁値が表と同じ94287082で返ります。最後の行の20000000000秒(2603年)まで通れば、64ビットのカウンターも合っています。
node --test手元の実行では、tests 6 / pass 6 / fail 0 でした。
使用済みコードを再利用させない
RFC 6238の5.2節は、同じ時間ステップ内では同じOTPが出るため、攻撃者が盗んだコードを同じ窓の中で使えてしまう点を指摘しています。そのうえで、検証側は最初の検証に成功したOTPの二度目の提示を受け付けてはならない(MUST NOT)としています。
実装は単純です。検証に成功したステップ番号を lastUsedStep として保存し、それ以下のステップに対応するコードは候補から外します。
export function verify(secret, code, unixSec, state, opts = {}) {
const { window = 1, digits = 6, algo = "sha1", period = 30 } = opts;
const now = stepOf(unixSec, period);
for (let s = now - window; s <= now + window; s++) {
if (s <= state.lastUsedStep) continue;
if (same(hotp(secret, s, digits, algo), code)) {
state.lastUsedStep = s;
return { ok: true, step: s };
}
}
return { ok: false };
}same は timingSafeEqual で比べる関数です。長さが違う入力を先に弾いておかないと timingSafeEqual が例外を投げるため、長さの比較を前に置いています。
テストは「成功した直後に、同じコードを1秒後にもう一度送る」形にします。同じステップなので、二度目は拒否されます。
実際のサービスでは lastUsedStep をデータベースに置くため、同じコードを同時に2リクエスト送られると、両方が「まだ未使用」と読む競合が起きえます。更新を WHERE last_used_step < 新ステップ の条件付きにして、更新件数が1のときだけ成功とみなします。この発想は、Claude Codeで楽観ロックを実装するで扱ったversionカラムの考え方と同じです。
時刻ずれはどこまで許すか
端末の時計とサーバーの時計は、完全には揃いません。RFC 6238の5.2節は、ネットワークの遅延を理由に許容するのは多くても1ステップまでを推奨しています。窓を広げるほど、盗まれたコードが使える時間も延びるためです。
6節の再同期の説明には、30秒ステップで過去2ステップまで許すと、許容できる最大のずれは約89秒(現在のステップ内の29秒と、過去2ステップの60秒)になるという計算があります。この記事の実装は前後1ステップを既定にしています。前後を許すと、ある時刻のコードは now − 1 から now + 1 の3つの値のどれとも一致できることになります。
テストでは境界を両側から確かめます。
- 30秒後のサーバーに送られたコード(1ステップ遅れ): 通る
- 30秒前のサーバーに送られたコード(1ステップ進み): 通る
- 60秒後のサーバーに送られたコード(2ステップ遅れ): 落ちる
端末の時計が大きくずれた利用者は、毎回落ちます。RFCは、ずれが許容範囲を超えたときは、別の認証手段で本人確認したうえで明示的に再同期するよう求めています。実装としては、リカバリコードで入れた利用者にもう一度TOTPを登録させる、という導線が現実的です。
試行回数を制限する
6桁のコードは、候補が100万通りしかありません。窓を前後1ステップ許すなら、1回の試行で当たる確率は3/1,000,000です。総当たりを許す設計にならないよう、失敗の回数を数えて一定数でロックします。
export function attempt(state, secret, code, unixSec, opts = {}) {
const { max = 5, lockSec = 300 } = opts;
if (state.lockedUntil > unixSec) return { ok: false, reason: "locked" };
const r = verify(secret, code, unixSec, state, opts);
if (r.ok) { state.failures = 0; return r; }
state.failures += 1;
if (state.failures >= max) {
state.lockedUntil = unixSec + lockSec;
state.failures = 0;
}
return { ok: false, reason: "invalid" };
}数える単位は、IPアドレスでなくアカウントにします。攻撃者はIPを変えられるからです。ロック中は正しいコードも拒否する点が、テストの重要な項目です。「5回失敗したあと、正しいコードを送ってもlockedが返る」「ロック時間が過ぎれば通る」の2本を書いておきます。
ロック時間を長くしすぎると、他人が故意に失敗を重ねて本人を締め出せます。アカウント単位のロックと、IP単位のレート制限を併用する構成は、Claude CodeでRedisのレート制限を実装するの仕組みに乗せられます。
シークレットの暗号化保存
RFC 6238の5.1節は、鍵を検証システム内で安全に保管し、暗号化して、必要なときだけ復号し、すぐに再暗号化してメモリ上の露出を短くするよう推奨しています。ここでいう「推奨」はSHOULD相当の勧告です。HSMのような耐タンパ装置が前提の書き方なので、アプリケーション側の実装では、認証付き暗号で保存する形が現実的な落としどころです。
export function seal(secret, key) {
const iv = randomBytes(12);
const c = createCipheriv("aes-256-gcm", key, iv);
const ct = Buffer.concat([c.update(secret), c.final()]);
return Buffer.concat([iv, c.getAuthTag(), ct]).toString("base64");
}AES-256-GCMは、暗号文が改ざんされると復号時に例外になります。テストで最後の1バイトを反転させ、open が例外を投げることを確認しておきます。
鍵そのものはコードやリポジトリに置かず、環境変数やシークレット管理サービスから渡します。Claude Codeに書かせるときは、「テスト用の鍵を .env にコミットしない」「例外メッセージにシークレットを入れない」をCLAUDE.mdの規約に入れておくと、書き込み前に止められます。誤ってコミットしたときの対応は、Claude Codeでシークレットをコミットした後の対応手順にまとめています。
リカバリコードは平文を保存しない
スマートフォンを失った利用者を救うのがリカバリコードです。パスワードと同じ考え方で、発行時に平文を一度だけ見せ、サーバーにはハッシュを保存します。
export function issueRecoveryCodes(n = 8) {
const plain = Array.from({ length: n }, () =>
randomBytes(5).toString("hex").replace(/(.{5})(.{5})/, "$1-$2"));
const hashes = new Set(plain.map(hashCode));
return { plain, hashes };
}
export function useRecoveryCode(hashes, code) {
return hashes.delete(hashCode(code));
}1つの値は10桁の16進数(40ビット)です。ランダムな40ビットは、総当たりを数回で当てられる大きさではありません。そのうえで、使うと集合から消えるので、同じコードは一度しか通りません。
テストは「1つ目を使うとtrue、同じコードをもう一度使うとfalse、残りは7個」の3点です。リカバリコードの入力にも、前節と同じ試行回数の制限をかけます。TOTPの失敗回数と合算して数えるのが安全です。
Claude Codeに渡す前後の確認項目
実装を任せたあとは、次の項目をテストか目視で確かめます。
| 確認項目 | 確認の仕方 |
|---|---|
| RFC付録Bの18値 | 確認の仕方テストが通る |
| 同じコードの二度目 | 確認の仕方拒否される |
| 前後1ステップの外 | 確認の仕方拒否される |
| 5回失敗後 | 確認の仕方正しいコードもロック中は拒否 |
| シークレットの保存形 | 確認の仕方DBに平文がない、改ざんで復号失敗 |
| リカバリコード | 確認の仕方保存はハッシュ、使うと消える |
Claude Codeが変更を一通り終えたら、「テストを実行して失敗を直して」と頼み、出力を読んでから次へ進みます。生成されたテストが期待値を実装の出力から作っていないか(RFC表との照合)は、人が見るべき箇所です。
実装を完成扱いにする前に
TOTPの実装は、この記事の範囲で最小限の骨格です。QRコードの生成、otpauth:// URIの組み立て、再登録時の旧シークレット破棄、端末ごとの管理は別に必要です。新しいシークレットを払い出すときは、確認コードの入力に成功してから有効化し、成功前に平文を保存しないようにします。
認証まわりは、テストが通っても、設計の抜けまでは見つけてくれません。計画モードで方針を確認し、RFCの表で計算を固め、再利用・時刻ずれ・試行回数の3つを境界値で試す。この順で進めると、Claude Codeが書いたコードの信頼できる範囲が、見える形で残ります。外部サービスへのログインにOAuthを使う場合は、Claude CodeでOAuth実装 — PKCE・state・リダイレクトURIをテストで固めるも参考になります。
参照した一次ソース
この記事の記述は、次の一次ソースと照合しています。更新時に参照先が変わった場合は一覧も更新します。