Claude Media
Claude Codeでargon2id/bcrypt移行を段階的に進める

Claude Codeでargon2id/bcrypt移行を段階的に進める

ログイン成功時にargon2idで再ハッシュする方式の実装、OWASPのパラメータ選び、平文をログやテストfixtureに残さないhooksまで、Claude Codeに任せる範囲と確認の仕方を示します。

bcryptで保存済みのパスワードを、全ユーザーに再設定を求めずにargon2idへ移すには、ログインに成功した瞬間に新方式で保存し直します。平文を持っているのはその瞬間だけだからです。この記事では、Node.jsのサービスを例に、その実装をClaude Codeに進めさせる手順と、確認のしかたをまとめます。

先に分担を示します。

  • アルゴリズムとパラメータの選定は人が決める
  • ログイン処理の書き換えとテストの作成はClaude Codeに任せる
  • 平文がログやテストデータに混ざるのをhooksで機械的に止める

移行は「ログイン時に再ハッシュ」で進める

OWASPのPassword Storage Cheat Sheetは、旧方式のハッシュは利用者がパスワードを入力したとき、その入力を新方式で再ハッシュして置き換える方法を示しています。移行期間は新旧の方式が混在するので、方式とコストを一緒に保存する標準形式を使うよう勧めています。例として挙がっているのがPHC文字列形式です。

argon2idのハッシュは $argon2id$v=19$m=... のようにPHC文字列形式で保存されます。bcryptのハッシュは $2a$ や $2b$ で始まります。先頭を見れば、どちらの方式で保存された行かを区別できます。この区別だけで、ログイン処理に分岐を足せます。

手順

ログイン時に行が移っていく流れ

  1. 1

    ハッシュの先頭で方式を判定する

    $2 で始まればbcrypt、$argon2id$ で始まればargon2idとして検証します。

  2. 2

    bcryptの検証に成功したら再ハッシュする

    入力された平文をargon2idでハッシュし、同じユーザー行を上書きします。

  3. 3

    argon2idの行はパラメータが古いときだけ再ハッシュする

    将来コストを上げたときも、同じ仕組みで追随できます。

先に人が決める3点

Claude Codeに実装を頼む前に、次の3点は人が決めます。ここが曖昧なまま渡すと、動くが根拠のない数字が入ります。

パラメータは出典のある組から選ぶ

OWASPはargon2idの最小構成として、メモリ・反復回数・並列度の組を複数挙げています。たとえば m=47104(46 MiB), t=1, p=1 と m=19456(19 MiB), t=2, p=1 はどちらも載っていて、同等の防御力でCPUとRAMの配分が違うだけだと説明されています。サーバーのメモリに余裕がなければ後者を選ぶ、といった判断になります。

注意したいのが、ライブラリの既定値との差です。Node.jsの argon2 パッケージのOptionsページでは、既定値は timeCost が3、memoryCost が65536(64 MiB)、parallelism が4と書かれています。OWASPの組は p=1 です。既定値のままで十分な場合もありますが、同時ログイン数が多いサービスでは、並列度4でスレッドごとにメモリを確保するコストが効いてきます。OWASPも、対象システムで実測してから決めるよう書いています。

採用前に自分のサーバーで測る

OWASPは、選んだパラメータを対象システムで実測するよう求めています。測る小さなスクリプトならClaude Codeに任せられます。頼むときは「本番と同じメモリ制限のコンテナで、同時に何件のログインが来ても耐えられるか」まで条件に書きます。

import argon2 from "argon2";
 
const opts = { type: argon2.argon2id, memoryCost: 19456,
  timeCost: 2, parallelism: 1 };
const t0 = performance.now();
await Promise.all(
  Array.from({ length: 20 }, () => argon2.hash("bench-only-pw", opts)),
);
console.log(`20並列: ${Math.round(performance.now() - t0)}ms`);

見るのは、1件あたりの所要時間より、ピーク時のログイン数に近い負荷での伸び方です。ここで余裕が見えない構成は、リリース後にログイン遅延として表に出ます。メモリ制限に近づくとプロセスごと落ちる可能性があるので、本番に近い制限で測ります。結果が許容範囲を超えるなら、OWASPの別の組(メモリを減らして反復を増やす構成)に変えて測り直します。

保存形式はPHC文字列のまま1列に入れる

方式やコストを別の列に分けず、ハッシュ文字列そのものに持たせます。既存のパスワード列をそのまま使え、スキーマ変更が要りません。bcryptの行も新方式の行も同じ列に入ります。

長く来ていない人の扱いを決める

ログイン時の再ハッシュだけでは、ログインしない人の行がbcryptのまま残ります。OWASPは対処を2つ挙げています。1つ目は、長期間使われていない行のハッシュを失効・削除し、次回はパスワード再設定を求める方法です。ユーザーには不親切で、サポートへの問い合わせが増えたり、侵害のお知らせと受け取られたりする点が指摘されています。2つ目は、既存のハッシュを新方式の入力にして重ねる方法です。平文なしで移せますが、ハッシュが破られやすくなるため、次回ログイン時に平文からの直接ハッシュへ置き換える前提です。

どちらを採るかは、サービスの性質と問い合わせ対応の体制で決まります。実装の前に決めておくと、Claude Codeへの指示が一貫します。

計画を立てさせてから書かせる

認証まわりの変更は、まず読むだけの段階を挟みます。Claude Codeにはプランモードがあり、ファイルを読んで計画を提案しますが、承認するまで編集しません。次のように起動します。

claude --permission-mode plan

起動後は、決めた内容を渡して計画を出させます。

ログイン処理をbcryptからargon2idへ段階移行したい。
- 方式: ログイン成功時に再ハッシュ。保存はPHC文字列で既存列に入れる
- パラメータ: m=19456, t=2, p=1
- bcryptの行は検証だけ許し、新規保存は必ずargon2id
まず、パスワードを検証・保存している箇所を全部挙げて、
変更計画を出して。編集はまだしないで。

計画で見るべきなのは、パスワードを扱う箇所がログインだけかどうかです。パスワード変更、ユーザー登録、管理者による再設定など、ハッシュを作る経路が他にもあれば、そこも新方式に揃えなければ、bcryptの行が増え続けます。

規約は CLAUDE.md に書いておくと、セッションをまたいで効きます。

## パスワード保存
- 新規保存は argon2id のみ(m=19456, t=2, p=1)。bcrypt で新規保存しない
- bcrypt は既存行の検証にだけ使う。`$2` で始まる行が対象
- パスワードをログ・エラーメッセージ・テストデータに書かない
- ハッシュ処理は src/auth/password.ts に集約する

ログイン処理の実装例

Node.jsの argon2 パッケージのWikiには、bcryptからの移行手順が載っています。ハッシュ文字列が $2 で始まるかで分岐し、bcryptで検証に成功したらargon2で再ハッシュするという流れです。ここに、パラメータの固定と、argon2idの行のコスト更新を足した例を示します。公式の考え方に沿った形で、そのまま貼れるものではありません。

import argon2 from "argon2";
import bcrypt from "bcrypt";
 
const OPTIONS = {
  type: argon2.argon2id,
  memoryCost: 19456,
  timeCost: 2,
  parallelism: 1,
};
 
type Save = (userId: string, hash: string) => Promise<void>;
 
export async function verifyAndUpgrade(
  user: { id: string; hash: string },
  plain: string,
  save: Save,
): Promise<boolean> {
  if (user.hash.startsWith("$argon2id$")) {
    const ok = await argon2.verify(user.hash, plain);
    if (ok && argon2.needsRehash(user.hash, OPTIONS)) {
      await trySave(save, user.id, await argon2.hash(plain, OPTIONS));
    }
    return ok;
  }
  if (user.hash.startsWith("$2")) {
    const ok = await bcrypt.compare(plain, user.hash);
    if (ok) {
      await trySave(save, user.id, await argon2.hash(plain, OPTIONS));
    }
    return ok;
  }
  return false;
}
 
async function trySave(save: Save, id: string, hash: string) {
  try {
    await save(id, hash);
  } catch {
    // 保存に失敗してもログイン自体は成功させる。次回ログインで再試行される
  }
}

trySave は設計上の判断です。書き込み失敗でログインまで失敗させると、移行の不具合が全員のログイン障害になります。失敗を握りつぶすと移行が進まないので、実運用では失敗件数をメトリクスに出す構成が前提になります。エラーを記録する場合も、平文やハッシュ全体は出さず、ユーザーIDだけにします。

移行の3パターンをテストで固定する

挙動の確認は、Claude Codeにテストを書かせて実行させ、結果を読ませる形にします。見るのは次の3パターンです。

テストの内容期待する結果
bcryptの行で正しいパスワード期待する結果ログイン成功、保存された値が $argon2id$ に変わる
bcryptの行で誤ったパスワード期待する結果ログイン失敗、保存された値は変わらない
argon2idの行で正しいパスワード期待する結果ログイン成功、パラメータが同じなら再保存しない

テストに使う旧ハッシュは、固定の文字列を貼らず、テスト内で生成します。

let legacy: string;
beforeAll(async () => {
  // テスト専用のダミー。実在のユーザーのハッシュは使わない
  legacy = await bcrypt.hash("test-only-pw", 4);
});

「テストを実行して、失敗したら原因を直して再実行して」と頼めば、Claude Codeはコマンドを実行し、出力を読んで修正を繰り返せます。私たちが見るのは、最終的に3パターンが通っているかどうかと、save が呼ばれた回数です。

hooksで平文とハッシュの混入を止める

CLAUDE.mdの規約は、守られない場合があります。守らせたい項目は、hooksで機械的に止めます。PreToolUse フックは、ツールの実行前に走り、終了コード2で呼び出しをブロックできます。ブロック時、フックが標準エラー出力に書いたメッセージがClaudeに返されます。

Edit と Write ツールの入力には、書き込み先の file_path と、内容を表す content(Write)または new_string(Edit)が入ります。これを検査します。

#!/bin/bash
# .claude/hooks/no-plaintext-password.sh
INPUT=$(cat)
FILE=$(jq -r '.tool_input.file_path // ""' <<<"$INPUT")
BODY=$(jq -r '.tool_input.content // .tool_input.new_string // ""' \
  <<<"$INPUT")
 
if grep -Eiq '(console\.(log|info|debug)|logger\.[a-z]+)\(.*(password|plain)' \
  <<<"$BODY"; then
  echo "Blocked: パスワード変数をログに出す記述です" >&2
  exit 2
fi
 
case "$FILE" in
  */fixtures/*|*/__fixtures__/*)
    if grep -Eq '\$2[aby]\$[0-9]{2}\$[./A-Za-z0-9]{53}' <<<"$BODY"; then
      echo "Blocked: fixtures に bcrypt ハッシュの直書きがあります" >&2
      exit 2
    fi ;;
esac
exit 0

jq が必要です。実行権限を付けたうえで、.claude/settings.json に登録します。

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Edit|Write",
        "hooks": [
          {
            "type": "command",
            "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/no-plaintext-password.sh"
          }
        ]
      }
    ]
  }
}

この正規表現で防げるのは、典型的な書き方だけです。console.log の引数に変数名が password や plain を含まない書き方は素通りします。検査をすり抜ける可能性は残るので、レビューの目は必要です。それでも、「ログに出すな」を口約束にせず、書こうとした時点で止まるようにしておく価値はあります。

完了の判定と、つまずきやすい点

移行がどこまで進んだかは、保存形式で数えられます。

SELECT
  SUM(hash LIKE '$2%')        AS bcrypt_rows,
  SUM(hash LIKE '$argon2id$%') AS argon2id_rows
FROM users;

bcryptの行がゼロになるか、残りを前述の失効方式で処理できる数まで減れば、bcrypt用のコードと依存を外せます。

落とし穴

bcryptは長いパスワードを途中で切る

OWASPは、bcryptの入力長は多くの実装で最大72バイトだと書いています。argon2idへ移ったあとも、入力上限を72バイトのまま残しておくと、長いパスフレーズを使う人の入力を不必要に制限します。上限を外す時期は、bcryptの行が残っているあいだは上限を維持し、移行完了後に見直す、と決めておくと混乱しません。

もう一つは、FIPS 140への準拠が求められる環境です。OWASPは、その場合はPBKDF2(HMAC-SHA-256で60万回以上)を使うよう書いています。argon2idへの移行がそもそも要件に合うかは、先に確認してください。

パスワード規程の側(最小長や定期変更の廃止)を見直す場合は、NIST 800-63Bでパスワード規程を見直すが扱っています。認証基盤そのものをAuth0から移すなら、descopeプラグインの記事が手順を示しています。シークレットを .env に置かない運用は、1Passwordプラグインの記事にあります。

まとめ

ログイン時の再ハッシュは、ユーザーに何も求めない移行です。難しいのはコードより、パラメータの根拠と、ログインしない人の扱いを先に決めておくことです。実装とテストはClaude Codeに任せ、平文の混入は PreToolUse フックで止める。この分担なら、確認する箇所を人がレビューできる範囲に絞れます。

参照した一次ソース

この記事の記述は、次の一次ソースと照合しています。更新時に参照先が変わった場合は一覧も更新します。

  1. cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html
  2. code.claude.com/docs/en/common-workflows
  3. code.claude.com/docs/en/hooks
  4. code.claude.com/docs/en/hooks-guide
  5. github.com/ranisalt/node-argon2/wiki/Migrating-from-another-hash-func…
この記事を共有:XはてブLinkedIn