Skip to content

feat: TOTP/HOTP ジェネレータを追加 (totp-hotp) - #422

Merged
fumtas1k merged 5 commits into
developfrom
feat/add-tool-totp-hotp
May 13, 2026
Merged

feat: TOTP/HOTP ジェネレータを追加 (totp-hotp)#422
fumtas1k merged 5 commits into
developfrom
feat/add-tool-totp-hotp

Conversation

@fumtas1k

@fumtas1k fumtas1k commented May 13, 2026

Copy link
Copy Markdown
Owner

概要

TOTP/HOTP ジェネレータを新ツールとして追加します。

  • Web Crypto API のみでブラウザ完結実装(依存追加ゼロ)
  • RFC 6238 (TOTP) / RFC 4226 (HOTP) 公式テストベクタで検証済み
  • シークレット鍵はサーバーへ一切送信しない設計

主な変更

  • src/utils/totp-hotp.ts: Base32 デコード・HMAC-OTP 計算・otpauth URI 生成
  • src/components/tools/TotpHotpGenerator.tsx: TOTP(リアルタイム更新)・HOTP・検証の3モード UI
  • src/pages/tools/totp-hotp.astro: ルーティングページ
  • unit test: RFC 公式ベクタ 28 ケース + 陽性対照含む計 56 テスト
  • E2E: 10 ケース(production CSP 下)、陽性対照(コロン含み issuer エラー検知)同梱
  • VRT 対象に PAGES 追加済み

設計上の判断

  • QR 直接描画を省略: secret 含む QR をページ内に自動表示すると画面録画で露出リスクがあるため、URI 出力 → /tools/qr-code へ手動コピー誘導
  • Base32 自前実装: npm 依存追加ゼロ(約 30 行、RFC 4648 §6 準拠)
  • Uint8Array<ArrayBuffer> 明示: TS 5.9.3 で Web Crypto API が要求する型と合致させるため

テスト計画

  • npm run test — 1228 passed (58 files)
  • node_modules/.bin/astro check — 0 errors
  • npm run test:e2e --grep TOTP — 10 passed
  • VRT baseline: PR マージ前に Update Visual Regression Baseline workflow を workflow_dispatch で実行(CI Linux runner 必須)

🤖 Generated with Claude Code

RFC 6238 (TOTP) と RFC 4226 (HOTP) のワンタイムコードをブラウザ上で
生成・検証するツールを追加。

- `src/utils/totp-hotp.ts`: Base32 デコード/エンコード、HOTP、TOTP、
  verifyTotp、buildOtpauthUri を Web Crypto API のみで実装(依存追加ゼロ)
- RFC 公式テストベクタ(HOTP Appendix D: 10 ケース、TOTP Appendix B:
  SHA-1/256/512 × 6 timestamp = 18 ケース)による unit test
- `TotpHotpGenerator.tsx`: TOTP(250ms ポーリング・プログレスバー)/
  HOTP(カウンタ生成)/ 検証モード、otpauth URI 表示
- E2E 10 ケース(CSP 適用下)、陽性対照(コロン issuer エラー検知)同梱
- シークレット鍵はサーバーへ送信せず、QR 直接描画も省略(外部露出防止)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@github-actions

github-actions Bot commented May 13, 2026

Copy link
Copy Markdown
Contributor

🖼️ Visual Regression Test 結果

  • Status: ✅ 全 40 件 pass
  • Workflow run: 25794590051
  • Artifact (diff 画像 / playwright-report): 上記 workflow run の Artifacts セクションから download

diff が 意図的な visual 変更の場合: Update Visual Regression Baseline workflow を本 PR ブランチで workflow_dispatch trigger して baseline を更新。
diff が 意図しない regression の場合: 該当変更を fix。
本 check は required ではないため fail のままでも merge は可能(reviewer 判断)。

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@fumtas1k fumtas1k self-assigned this May 13, 2026
@fumtas1k fumtas1k closed this May 13, 2026
@fumtas1k fumtas1k reopened this May 13, 2026
github-actions Bot added a commit that referenced this pull request May 13, 2026
@fumtas1k

Copy link
Copy Markdown
Owner Author

多角レビュー(セキュリティ / フロントエンド / ロジック / アーキテクチャ)

別 Claude セッションで独立レビューを実施。RFC ベクタ+陽性対照を含む高水準のテスト規律と、Web Crypto API のみで package.json を増やさない実装方針は秀逸です。一方で 1 件の E2E broken assertion と、強化推奨ポイントを以下にまとめます。


✅ 良い点

  • 暗号実装: HMAC は crypto.subtle 委譲。Counter は 8-byte big-endian、dynamic truncation も RFC 4226 §5.4 準拠。hotp / totp / verifyTotp がそれぞれ純関数として切り出され、unit test しやすい
  • テスト規律: RFC 4226 Appendix D / RFC 6238 Appendix B SHA-1/256/512 全 28 ベクタ + 陽性対照(Base32 不正文字・issuer コロン)が網羅されており、test-gates skill の趣旨に整合
  • セキュリティ的判断: secret 含む QR を自動描画せず /tools/qr-code へ手動誘導(decisions.md [079-2])。シークレット入力欄は type="password" 初期値
  • 依存ゼロ: Base32 自前実装 ~30 行で package.json を増やさない方針が守られている
  • ドキュメント整合: README / SPEC / decisions.md / VRT pages 配列まで漏れなく更新

🔴 修正推奨(high)

[H1] E2E のプレースホルダー比較が文字数ミスマッチで実質 no-op
tests/e2e/totp-hotp.spec.ts:29,42not.toHaveText('─────')5 文字ですが、コンポーネント側のプレースホルダーは '─'.repeat(digits) = 6 文字(──────)。比較対象が一致しないため、コード生成完了を待たず即 pass し、回帰検知能力が失われています。

- ).not.toHaveText('─────');
+ ).not.toHaveText('──────');

digits が変動するなら not.toHaveText(/^[─\s]+$/) のような regex で書く方が堅牢です。


🟡 修正推奨(medium)

[M1] verifyTotp の定数時間比較
src/utils/totp-hotp.ts:111expected === code は早期 return する非定数時間比較。クライアント完結ツールでタイミング攻撃の現実的脅威は極めて低いものの、暗号系ライブラリのベストプラクティスとして以下のような桁単位 XOR-and-OR の constant-time 比較を推奨します。

function timingSafeEqual(a: string, b: string): boolean {
  if (a.length !== b.length) return false;
  let diff = 0;
  for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
  return diff === 0;
}

[M2] Base32 デコードで invalid length が silent truncation
base32Decode は不正文字は throw しますが、長さが {2,4,5,7,0} mod 8 以外(例: 1, 3, 6 文字)の場合に末尾 bit を黙って破棄します。「タイポで 1 文字落ちた secret」を validate せず使ってしまい、得られる OTP が常に不一致になる原因を user 側で特定しづらくなります。

const validRemainders = new Set([0, 2, 4, 5, 7]);
if (!validRemainders.has(s.length % 8)) {
  throw new Error(`Invalid Base32 length: ${s.length}`);
}

[M3] tick() が 250ms ごとに Base32 デコードを再実行
TotpHotpGenerator.tsx:91-115 の useEffect 内で、setInterval が 4Hz で動くたびに base32Decode(secretBase32.trim()) を呼び出しています。decode 結果は secret 不変なら同一なので、useMemo で 1 度だけ計算した bytes を tick に渡す形が望ましい。あわせて秒単位カウントダウン用途には 1000ms 間隔で十分(CPU 削減)。

const secretBytes = useMemo(() => {
  try { return base32Decode(secretBase32.trim()); }
  catch { return null; }
}, [secretBase32]);

🟢 nit / 設計コメント

  • [N1] parseInt(counterStr, 10) || 0 (TotpHotpGenerator.tsx:135,175): 空文字や非数値を silent に 0 に丸める。HOTP は counter 不一致が即認証失敗に直結するため、isNaN 時はエラー表示の方が user 親切
  • [N2] 検証モードの E2E アサーションが弱い (tests/e2e/totp-hotp.spec.ts:108-110): getByRole('region', { name: /有効|無効/ }).or(...)or fallback により実質「何か aria-live 要素があれば pass」になっている。getByText('有効').or(getByText('無効')) などで結果テキストを直接検証する方が回帰検知力が上がる
  • [N3] buildOtpauthUri の secret URL-encode: Base32 文字(A-Z, 2-7)は URL safe なので素のままで実害は出ないものの、padding = を含む secret を渡すと URI に = がそのまま出る。実 secret は今回 padding 除去済の値が渡る想定なので OK ですが、encodeURIComponent(opts.secretBase32) で defensive にしておくとリスクが消える
  • [N4] コンポーネント 456 行: 3 モード分の state と effect を 1 コンポーネントで抱えている。将来モード追加時に肥大化しやすいので、<TotpPanel /> / <HotpPanel /> / <VerifyPanel /> への切り出しが視野に入る(本 PR スコープ外)

総合評価

APPROVE 推奨([H1] のみ本 PR 内で修正してほしい)。RFC 準拠の正確性とテスト規律は高水準で、設計上の判断(QR 直接描画回避・依存ゼロ)が decisions.md に明文化されているのも好印象です。[M1]〜[M3] は別 PR / 別 issue で追従可能。

PR #422 多角レビューでの指摘を本 PR で巻き取り。

- [H1] E2E placeholder 比較を regex 化(旧 `'─────'` 5文字 vs 実装 6文字で
  即 pass する no-op 状態を解消)
- [M1] `verifyTotp` を constant-time 比較化(`timingSafeEqual` 追加、window
  全件走査して早期 return も廃止)
- [M2] `base32Decode` で RFC 4648 §6 の有効長 (mod 8 ∈ {0,2,4,5,7}) を検証。
  陽性対照テスト 3 ケース同梱(1/3/6 文字 → throw)
- [M3] `secretBytes` を useMemo でキャッシュし 250ms tick での再デコードを廃止
- [N1] HOTP カウンタの非数値・負値を silent に 0 へ丸めず明示エラー表示
- [N2] 検証モード E2E の `or` fallback を廃止、`toContainText(/有効|無効/)` で
  結果テキストを直接 assert(aria-live 要素の存在だけで pass を解消)
- [N3] `buildOtpauthUri` で secret を `encodeURIComponent` で defensive に encode

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@fumtas1k

Copy link
Copy Markdown
Owner Author

レビューありがとうございます。[N4](コンポーネント分割)以外を本 PR で巻き取りました(commit d5e3d96)。

対応内容

  • [H1] E2E placeholder を regex ^[─\s]+$ に変更し、桁数差異に依存せず「コード未生成」を確実に検出するよう修正
  • [M1] timingSafeEqual を追加し、verifyTotp を window 全件走査・早期 return なしの constant-time 化
  • [M2] base32Decode で RFC 4648 §6 の有効長 (mod 8 ∈ {0,2,4,5,7}) を validate。陽性対照テスト 3 ケース(1/3/6 文字 → throw)同梱
  • [M3] secretBytesuseMemo でキャッシュし、250ms tick での再デコードを廃止。なお progress bar の滑らかさを保つため interval は 250ms 維持(HMAC 計算は同 counter 内で同じ結果になるが、最適化は本 PR スコープでは過剰)
  • [N1] HOTP カウンタの非数値・負値を parseInt(...) || 0 で silent に丸める動作を廃止し、ErrorMessage で「カウンタは 0 以上の整数を入力してください」を表示
  • [N2] 検証モード E2E の or fallback を廃止、toContainText(/有効|無効/) で結果テキストを直接 assert
  • [N3] buildOtpauthUri で secret を encodeURIComponent でラップ(padding = 含む secret の query 区切り衝突を defensive に防止)

動作確認

  • node_modules/.bin/astro check: 0 errors
  • npm run test: 1237 passed (58 files、+9 件)
  • npm run test:e2e --grep TOTP: 10 passed
  • Playwright 実機: HOTP モードで -5 入力 → カウンタは 0 以上の整数を入力してください 表示を確認

[N4] のコンポーネント分割は本 PR スコープから外し、別 issue で追従検討します。

@fumtas1k

Copy link
Copy Markdown
Owner Author

再レビュー(fix commit d5e3d96

前回指摘 H1 / M1〜M3 / N1〜N3 の 全 7 項目 が本 PR 内で巻き取り済み。CI も test / visual-regression pass、e2e 進行中。LGTM です。


確認結果

ID 指摘内容 対応 評価
H1 E2E プレースホルダー比較が 5 文字 vs 実装 6 文字で no-op regex ^[─\s]+$ 化(tests/e2e/totp-hotp.spec.ts:29,42,98 ✓ digits 7/8 拡張にも追従できる堅牢な書き方
M1 verifyTotp の非定数時間比較 timingSafeEqual 追加、window 全件走査・早期 return 廃止(src/utils/totp-hotp.ts:111-135 ✓ コメントで現実的脅威は低いことを明示している点も好印象
M2 Base32 invalid length silent truncation VALID_BASE32_REMAINDERS = {0,2,4,5,7} で validate、陽性対照 3 ケース同梱 ✓ コメントが RFC 4648 §6 を明示参照していて maintainability ◎
M3 250ms tick での Base32 再 decode useMemo<Uint8Array<ArrayBuffer> | null> で secret bytes キャッシュ、effect deps を secretBytes に切替 ✓ secretError を useState から派生 state に再構成した副次クリーンアップも良い
N1 HOTP counter の silent 0 丸め counterError で非数値・負値を明示エラー表示、<ErrorMessage /> 連動
N2 検証 E2E の or fallback で silent regression toContainText(/有効|無効/) で結果テキストを直接 assert
N3 buildOtpauthUri の secret 未 encode encodeURIComponent(opts.secretBase32) で defensive 化

微小な気づき(blocker ではない、本 PR で対応不要)

  • constant-time の厳密性: verifyTotp 内の if (timingSafeEqual(expected, code) && !result.valid)result.valid の短絡で代入の有無に微小な時間差が出得ます。理論的には全 window 走査の最大時間に揃えるなら result = timingSafeEqual(...) && !result.valid ? {valid:true,offset} : result で常に三項演算する方が defensive。ただし JS の microtask ノイズの方が支配的なので実害なし
  • crypto.subtle.importKey の再呼び出し: hotp が呼ばれるたびに毎回 import している。verify では window=1 で 3 回。秘密鍵のサイズが小さく実害は無視できるレベルですが、将来 window 拡張時の perf を考えると verifyTotp 内で 1 度だけ import → 各 counter で sign する形に refactor の余地あり
  • secret padding の URL encode 影響: encodeURIComponent('ME======') = 'ME%3D%3D%3D%3D%3D%3D'。一部の旧 authenticator アプリは %3D を decode しない実装もあるとの報告あり(実害は限定的)。今回は input 側で padding を strip しないので、利用者が = 込みで貼り付けた場合のみ発生。優先度低
  • エラー文言: 「長さ 2/4/5/7/8n 文字」の 8n が一瞬「8 と n」に読めるので、「2/4/5/7 文字 または 8 の倍数」表記の方が user にとって明瞭

総合評価

APPROVE ✅ — H1 を含む全指摘が本 PR 内で適切に解決。中盤の useMemo 化で secretError を派生 state に変えたリファクタは元の指摘範囲を超えた好改善でした。merge 後の追従 issue 化も不要と判断します。

PR #422 再レビュー指摘 (#4): "8n" が「8 と n」に読めるため可読性が低い。
"2/4/5/7 文字 または 8 の倍数" 表記で意図を明確化。

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@fumtas1k

Copy link
Copy Markdown
Owner Author

再レビューありがとうございます。APPROVE いただきました。気づき項目について判断しました。

ID 内容 判断
#4 エラー文言「8n 文字」が読みにくい 本 PR で対応(commit db8062e、「2/4/5/7 文字 または 8 の倍数」表記に変更)
#1 constant-time の && !result.valid 短絡 起票せず(JS microtask ノイズが支配的で実害なし)
#2 crypto.subtle.importKey 再呼び出し 起票せず(現状 window=1 固定、将来拡張時に再検討)
#3 secret padding %3D 互換性 起票せず(input 側で = strip 済みのため発生条件なし)

E2E は文言の部分文字列マッチなのでこの修正は影響しません(10 passed 確認済み)。

merge 準備完了です。

@fumtas1k
fumtas1k merged commit 9002c44 into develop May 13, 2026
3 checks passed
@fumtas1k
fumtas1k deleted the feat/add-tool-totp-hotp branch May 13, 2026 10:57
fumtas1k added a commit that referenced this pull request May 13, 2026
release: develop → main (#422 #423 #425 TOTP/HOTP ジェネレータ追加)
fumtas1k added a commit that referenced this pull request Jun 13, 2026
S/A/B tier の各表に「状態」列を追加し、実装済み 6 候補(regex-visualizer #490 / config-converter #133 / totp-hotp #422 / cidr-calculator #582 / secret-scrubber #631 / clipboard-inspector #635)に ✅ と PR 番号を記載。冒頭の実装状況メモは状態列の説明に置換し、common.md 5 章にマージ時の更新ルールを追記。

🤖 Generated with [Claude Code](https://claude.com/claude-code)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant