StreamキーをローテートするときにDB上の指紋も一緒に更新しないと二重登録チェックが機能しない話
はじめに
配信プラットフォームを開発していると、「ユーザーが配信キーを変更できる機能」は自然と実装されます。しかし今回、初回登録時には指紋(フィンガープリント)を保存しているのに、更新時には指紋を更新していないという非対称なバグを見つけました。
「指紋」とは、同じキーが複数のアカウントに二重登録されていないかを確かめるために保存するハッシュ値です。暗号化した値(streamKeyEnc)は毎回異なる暗号文になるため、一致比較に使えません。そこで一方向ハッシュを別列に保持します。
キーを変えても指紋が古いままだと、新しいキーを別のアカウントが既に使っていても気づかずに登録できてしまうという問題が起きます。本記事では、その発見から修正・テストまでの流れを整理します。
やったこと
1. 問題のある更新処理を確認する
Next.js の Server Actions(actions.ts)に、配信キーを更新するコードがありました。概略はこうです。
// 更新前のコード(問題あり)
await db
.update(streams)
.set({
streamKeyEnc: encryptedKey, // 暗号化キーは更新している
// streamKeyFingerprint が抜けている ← ここが問題
})
.where(eq(streams.userId, userId))
一方、初回登録時のコードはこうなっていました。
// 登録時のコード(手本)
await db.insert(streams).values({
userId,
streamKeyEnc: encryptedKey,
streamKeyFingerprint: computeFingerprint(rawKey), // 指紋も保存している
})
登録と更新で処理が非対称になっていたのが根本原因です。
2. 指紋を作る関数を探す(新しく作らない)
指示書に「指紋の作り方を新しく発明しない」とあったとおり、まず既存の computeFingerprint 関数がどこで定義されているかを grep で探しました。
grep -r "computeFingerprint\|streamKeyFingerprint" apps/web --include="*.ts" -l
共有パッケージ(packages/ 配下)に定義されており、初回登録時も同じ関数を呼んでいることが確認できました。
3. 更新処理に指紋の同期を追加する
修正は直感的です。暗号化キーを更新する set() の中に、同じ関数で計算した指紋を追加するだけです。
// 修正後のコード
const fingerprint = computeFingerprint(rawKey)
await db
.update(streams)
.set({
streamKeyEnc: encryptedKey,
streamKeyFingerprint: fingerprint, // 指紋も同時に更新
})
.where(eq(streams.userId, userId))
さらに、「新しいキーが既に別のアカウントに登録されていないか」を更新前に確かめる処理も追加しました。
// 重複チェック(更新前に実行)
const existing = await db
.select({ id: streams.id })
.from(streams)
.where(
and(
eq(streams.streamKeyFingerprint, fingerprint),
ne(streams.userId, userId) // 自分自身は除外
)
)
.limit(1)
if (existing.length > 0) {
return { error: "このキーは既に別のアカウントで使用されています" }
}
4. テストを書く
既存のテストファイルを参照しながら、Vitest でテストを追記しました。
describe("updateStreamKey", () => {
it("指紋が新しいキーの値で更新される", async () => {
// ダミーキーを使う(本物らしい値は使わない)
const dummyKey = "test-stream-key-dummy-0001"
await updateStreamKey({ userId: "user-1", rawKey: dummyKey })
const result = await db
.select({ fingerprint: streams.streamKeyFingerprint })
.from(streams)
.where(eq(streams.userId, "user-1"))
expect(result[0].fingerprint).toBe(computeFingerprint(dummyKey))
})
it("他アカウントが使用中のキーを登録しようとするとエラーになる", async () => {
const dummyKey = "test-stream-key-dummy-0002"
// user-2 が既に同じキーを使っている状態を作る
await db.insert(streams).values({
userId: "user-2",
streamKeyEnc: "encrypted-dummy",
streamKeyFingerprint: computeFingerprint(dummyKey),
})
const result = await updateStreamKey({ userId: "user-1", rawKey: dummyKey })
expect(result.error).toBeDefined()
})
})
ハマったポイント
共有パッケージのビルド成果物が存在しない
最初、共有パッケージのエントリポイント(packages/xxx/dist/index.js)から computeFingerprint をインポートしようとしたところ、ビルドされた成果物がワークスペースに存在しないためにテストが即エラーになりました。
Error: Cannot find module 'packages/crypto/dist/index.js'
今回の制約として pnpm build を実行できないため、dist 経由ではなく TypeScript ソースを直接インポートするパスに切り替えました。
// NG: ビルド成果物を参照
import { computeFingerprint } from "@loopcast/crypto"
// OK: ソースを直接参照(テスト環境限定の回避策)
import { computeFingerprint } from "../../packages/crypto/src/index"
(個人の感想)本来は CI でビルドされた成果物が存在するはずであり、ローカルのワークツリーが不完全な状態でした。本番コードはエントリポイント経由のままで問題なく、テストファイルのみこの回避策を適用しました。
陽性対照(修正を一時的に外したときのテスト確認)
「指紋の更新行を外したら、何件のテストが落ちるか」を確かめるのが完了条件に含まれていました。set() から streamKeyFingerprint の行を削除した状態で実行すると:
- 「指紋が新しいキーの値で更新される」 → FAIL
- 「他アカウントが使用中のキーを登録しようとするとエラーになる」 → FAIL(指紋が更新されないので重複も検出できない)
合計 2件 のテストが落ちました。これにより、テストが実装の変更に正しく反応することを確認してから、修正を書き戻してコミットしました。
学び
1. 登録と更新の非対称は起きやすい
今回のバグは珍しくありません。「登録」のコードを書いたときはすべての列を意識しているのに、「更新」のコードは「今回変えたい列だけ書く」という発想になりがちです。その結果、派生列(ここでは指紋)の更新が漏れます。
派生列(暗号化した元の値から計算した列など)は、元の値を変えるすべての場所でセットで更新するというルールを関数コメントや型に落としておくと良いでしょう。
2. 「発明しない」という原則の価値
今回の指示書には「指紋の作り方を新しく発明しない」とありました。独自実装を増やすと、「どの関数が信頼できる唯一の実装か」がコードベースで曖昧になります。既存の computeFingerprint を使うと決めることで、「指紋の定義は1箇所」という不変条件を保てました。
3. ビルド成果物に依存するテストは脆い
共有パッケージのビルドが前提になっているテスト環境では、ビルドが走っていない状態でテストが通らなくなります。モノレポの CI パイプラインで「テストの前に依存パッケージをビルドする」ステップが欠かせません。ローカル開発中にこの問題に当たったときは、ソース直接参照への一時的な切り替えが現実的な回避策です。
4. 陽性対照テストは「直しを外して確認」する
テストを書いたあと「このテストは実際に壊れたコードで落ちるか」を確かめる工程(陽性対照)を省略すると、常に通るテストを書いてしまうリスクがあります。今回は修正行を一時的にコメントアウトして落ちることを確認し、それから書き戻してコミットしました。この習慣は、テストそのものの品質保証として有効です。
まとめ
| 観点 | 修正前 | 修正後 |
|---|---|---|
| 暗号化キーの更新 | ✅ | ✅ |
| 指紋の更新 | ❌(古いまま) | ✅ |
| 重複チェック(更新時) | ❌(機能しない) | ✅ |
変更したファイルは apps/web/app/(dashboard)/stream/actions.ts(更新処理に約10行追加)とテストファイル(約40行追加)の2ファイルです。
「暗号化した値を保存するときは、その派生列(指紋・ハッシュ)も一緒に更新する」——これを習慣化するだけで、今回のような非対称バグを防げます。