Romira's develop blog

Chrome 146でStable化されたDBSCをRustで実装してみた

Cover image of Chrome 146でStable化されたDBSCをRustで実装してみた

はじめに

2025年5月、Chrome 146 (Windows) で Device Bound Session Credentials (DBSC) がデフォルト有効になりました。

W3C WebAppSec ワーキンググループが策定中のプロトコルで、Cookie 窃取によるセッションハイジャックへの対策として設計されたものです。

Chrome 146 で Stable 化されたとのことなので、本ブログ(Leptos + Axum)の管理画面認証に組み込んでみました。

まだ実装例がほとんどなく、デバッグの困難さや実装上のハマりどころなど、実装して初めてわかったことが多かったのでまとめます。

なお、DBSC は現時点で Chrome (Windows) のみ対応です。macOS は Chrome 147 で段階的ロールアウト予定、他ブラウザは未対応です。

ただし、macOS でも chrome://flags で以下の 2 つのフラグを有効にすると、ソフトウェアキーによるテスト動作が可能です(TPM ではなくソフトウェアバックの鍵を使用するため、本番のセキュリティは保証されません)。

  • #enable-bound-session-credentials-software-keys-for-manual-testingEnabled
  • #enable-standard-device-bound-session-credentialsEnabled - For develop
スクリーンショット 2026-04-04 10.18.16.png

本ブログの認証構成

本ブログの管理画面は以下の構成でセッション管理しています。

  • Google OAuth 2.0 でログイン
  • tower-sessions + Valkey(Redis 互換)でセッション管理
  • セッション Cookie は長寿命(Secure; HttpOnly; SameSite=Lax; Max-Age=2週間

この構成の課題は、Cookie が窃取された場合に攻撃者がセッションを有効期限(2週間)の間ずっと利用し続けられることです。DBSC はこの問題を解決するプロトコルです。

DBSC とは

DBSC (Device Bound Session Credentials) は、セッション Cookie をデバイスに紐付けるプロトコルです。仕組みを簡単に説明します。

  1. ブラウザが TPM / セキュアストレージに秘密鍵を生成する(アルゴリズムはサーバーが指定。ES256, RS256 等)
  2. サーバーが公開鍵をセッションに紐付ける(Registration)
  3. セッション Cookie を**短寿命(10分)**にする
  4. Cookie 失効時、ブラウザが秘密鍵で署名した JWT をサーバーに送り、Cookie を再発行してもらう(Refresh)

秘密鍵はデバイスから取り出せないため、Cookie を窃取しても別デバイスでは更新できません。窃取された Cookie は最大 10 分で無効になります。

設計方針:既存 Cookie の短寿命化 vs DBSC 専用 Cookie の追加

DBSC を既存の認証構成に組み込む方法として、2つの方式を検討しました。

既存の Cookie を短寿命化する方式(不採用)

tower-sessions のセッション Cookie 自体を短寿命化し、DBSC で更新させる方式です。Cookie は既存の 1 つだけで済みます。

不採用理由:

  • DBSC 非対応ブラウザでセッションが 10 分で切れる: セッション Cookie 自体を短寿命にするため、DBSC 非対応ブラウザは Refresh できず 10 分でログアウトされる。非対応ブラウザとの共存ができない
  • 実装の複雑化: tower-sessions の Cookie 管理と DBSC の Cookie 管理が同一 Cookie を対象にするため、両者の制御が競合する

DBSC 専用の Cookie を追加する方式(採用)

tower-sessions の Cookie(長寿命、2週間)はそのまま維持し、DBSC 用の __Secure-dbsc Cookie(短寿命、10分)を別途追加する方式です。

Cookie 用途 属性 寿命
id (tower-sessions) ユーザーセッション Secure; HttpOnly; SameSite=Lax; Path=/ 2週間
__Secure-dbsc DBSC バインド Cookie Secure; HttpOnly; SameSite=Lax; Path=/ 600秒(10分)

窃取後の最大有効時間は既存 Cookie を短寿命化する方式と同じ 10 分ですが、既存のセッション管理に一切手を加えずに済むためこちらを採用しました。

実装フロー

DBSC のフローは Registration(初期登録)と Refresh(Cookie 更新)の 2 段階です。

エンドポイント一覧

エンドポイント メソッド 用途
/auth/dbsc/registration GET Registration 開始(nonce 保存 + ヘッダー + 303 /admin)
/auth/dbsc/registration POST Chrome の Registration POST 処理
/auth/dbsc/refresh POST Refresh Phase 1 / Phase 2

Registration フロー

dbsc-registration.webp

OAuth ログイン後、JS リダイレクトで Registration 開始ページに遷移し、そこから Secure-Session-Registration ヘッダー付きの 303 レスポンスを返すことで Registration を開始します。

OAuth コールバックから直接 Registration を開始しない理由は後述します。

Refresh フロー

dbsc-refresh.webp

__Secure-dbsc Cookie 失効後、Chrome が自動的に Refresh を実行します。Phase 1 でサーバーが challenge nonce を発行し、Phase 2 でブラウザが秘密鍵で署名した JWT を返します。Chrome は cached challenge を持っていると次回 Phase 1 をスキップして直接 Phase 2 を送信します。

そのため、Phase 2 の 200 OK では次の nonce を Secure-Session-Challenge で返す必要があります。 含めないと、Chrome が cached challenge で Phase 1 をスキップした際にサーバー側に対応する nonce がなく失敗します。

なお、仕様(§5)では nonce の単一使用は要求されておらず、サーバーは直近の nonce を複数保持して "recent" であれば受け入れるよう求められています。

セッション設定 JSON

Registration POST で返却する session config JSON は以下のとおりです。

{
  "session_identifier": "<uuid v7>",
  "refresh_url": "/auth/dbsc/refresh",
  "scope": {
    "origin": "https://blog.romira.dev",
    "include_site": false,
    "scope_specification": [
      { "type": "include", "domain": "blog.romira.dev", "path": "/admin" },
      { "type": "include", "domain": "blog.romira.dev", "path": "/api/admin" }
    ]
  },
  "credentials": [{
    "type": "cookie",
    "name": "__Secure-dbsc",
    "attributes": "Secure; HttpOnly; SameSite=Lax; Path=/"
  }]
}

scope_specification/admin/api/admin を指定しているため、Chrome はこれらのパスへのリクエスト時のみ __Secure-dbsc Cookie の存在を確認します。

include_site はサブドメインを含むサイト全体(登録可能ドメイン全体)を DBSC の対象にするかどうかの設定で、true にするとルートドメインの /.well-known/device-bound-sessions で許可されたオリジンか確認されます。本ブログはサブドメインを使っていないため false にしています。

Chrome は refresh_url を自動的に Exclude ルールに追加するため、Refresh リクエスト自体が Refresh をトリガーする無限ループは発生しません。

session config の注意点

項目 Chrome の挙動 根拠
include_site: true .well-known/device-bound-sessions が必要。なければエラー 仕様 §8.9
attributesMax-Age エラー(kInvalidCredentialsCookieUnpermittedAttribute Issue #230Chromium ソース
attributes に許可される値 Domain, Path, Secure, HttpOnly, SameSite のみ 同上

attributesMax-Age を含めるとエラーになる点は仕様に明記されておらず、AI に調査させたところ Chromium のソース(cookie_craving.cc L128)から判明しました。

Cookie の寿命はサーバーが Set-Cookie で制御するものであり、session config の attributes はあくまで Cookie の属性テンプレートという位置付けです。

ミドルウェアでの DBSC 検証

require_admin_auth ミドルウェアでの DBSC 検証フローです。DBSC 非対応ブラウザでも管理画面が従来どおり使えるよう、DBSC 登録済みセッションでのみチェックを行います。

リクエスト (/admin or /api/admin/)
  │
  ├── ユーザー未認証 → 401 or リダイレクト
  │
  └── ユーザー認証済み
        │
        ├── session に dbsc_session_id なし(DBSC 未登録)
        │     → __Secure-dbsc チェックなし → 通過
        │
        └── session に dbsc_session_id あり(DBSC 登録済み)
              │
              ├── __Secure-dbsc Cookie あり → 通過 ✅
              └── __Secure-dbsc Cookie なし → ブロック ❌

DBSC 未登録のセッション(=非対応ブラウザ)では Cookie チェックをスキップするため、従来どおり動作します。DBSC が登録されたセッションでのみ、__Secure-dbsc Cookie の存在を必須にします。

Registration POST で Cookie が除外される問題

実装中に最もハマった問題です。

問題

OAuth コールバック後に Secure-Session-Registration ヘッダーを返して Registration を開始すると、Chrome が送信する Registration POST で SameSite=Lax のセッション Cookie が除外されます。

原因は Chrome の initiator 継承にあります。Registration POST の initiator は、レスポンスのオリジンではなくリクエストの initiator から継承されます。

OAuth コールバックは accounts.google.com からのリダイレクトであるため、initiator が cross-site になり、POST で SameSite=Lax Cookie が除外されます。

これは w3c/webappsec-dbsc#106 で報告されており、Chrome 開発者は以下のように説明しています。

"DBSC requests inherit the context of the request that caused them."

"I'm quite fond of the fact that none of the DBSC requests can cause SameSite issues because they're copies of existing requests."

意図的な設計であり、バグではありません。

解決:JS リダイレクトで initiator をリセット

同 Issue で Chrome 開発者が提示した回避策「クライアントサイドリダイレクト」を採用しました。

  1. auth_callback → HTML + <script>location.href='/auth/dbsc/registration'</script> を返す
  2. JS リダイレクトにより initiator が自サイトにリセットされる
  3. GET /auth/dbsc/registration → nonce をセッションに保存 + Secure-Session-Registration ヘッダー + 303 /admin
  4. Registration POST → セッション Cookie 送信 ✅ → セッションに直接読み書き可能

303 サーバーリダイレクトでは initiator がリセットされないことを実機検証で確認しました。

Grafana のログで Registration POST 時に stored_session_id=None(=セッション Cookie が送信されずセッションが読めない)を観測し、JS リダイレクトに切り替えたところ解決しました。

DBSC のデバッグは困難

DBSC の実装で最も苦労したのはデバッグです。

DBSC リクエスト(Registration POST / Refresh)は DevTools の Network タブに表示されません。Chrome 内部で処理されるためです。

chrome://device-bound-sessions でイベント履歴は確認できますが、表示されるのは「Endpoint transient error」「Transient HTTP error」のような抽象的なエラーメッセージのみです。

dbsc-chrome-endpoint-transient-error.png

nonce 不一致なのか、session ID 不一致なのか、JWT 検証失敗なのか、ブラウザ側からは判別できません。

chrome://net-export で全リクエストを JSON 保存する方法もありますが、DBSC 以外の全通信も含まれるため量が膨大で、デバッグを回すには不向きです。

結局、サーバー側でリクエストの全状態をダンプするデバッグログを仕込み、Grafana でクエリするのが一番手っ取り早かったです。

リクエストヘッダー、Cookie、セッション状態を tracing で出力し、OTLP 経由で送信します。本番環境では送信先を New Relic にしていますが、OTLP 対応のバックエンドなら送信先を変えるだけで同じログが使えるので、開発時は grafana/otel-lgtm で Grafana を立てて使いました。

cached challenge の不在、session ID 不一致など、原因は全てサーバーログから発見しました。

同一秒に複数 Refresh が発生する

__Secure-dbsc Cookie 失効時にページ遷移すると、同一秒に複数の Refresh が発生することを観測しました。

dbsc-chrome-events-challenge-refresh.png

1 秒間に 3 つの POST /auth/dbsc/refresh が送信されています。

dbsc-grafana-refresh-traces.png

サーバーログのタイムスタンプ:

23:39:56.947  Phase 2 SUCCESS → Set-Cookie Max-Age=10
23:39:56.984  Phase 2 SUCCESS → Set-Cookie Max-Age=10  (37ms後)
23:39:57.112  Phase 2 SUCCESS → Set-Cookie Max-Age=10  (128ms後)

ページロード時に複数のリクエスト(SSR HTML + server function API 等)が __Secure-dbsc Cookie を必要とし、短時間に連続して Refresh が発生していると考えられます。

複数 Refresh が発火しても全て成功するため通常は問題ありませんが、極端に短い Max-Age だと高頻度の署名要求で Chrome の TPM クォータ(Signing quota exceeded)に達し、Session Termination が発生します。

本番環境では Max-Age=600(10分)にしており、この問題は起きていません。

セキュリティモデル

DBSC による保護を攻撃シナリオ別に整理します。

シナリオ 結果
セッション Cookie のみ窃取 __Secure-dbsc 不在 → 即ブロック
両 Cookie 同時窃取 __Secure-dbsc が 10 分で失効 → 以降ブロック
窃取後に Refresh 試行 TPM 秘密鍵がない → 更新不可
DBSC 非対応ブラウザ dbsc_session_id なし → チェックスキップ(従来どおり動作)

従来はセッション Cookie を窃取されると有効期限(2週間)の間ずっと利用されるリスクがありましたが、DBSC により最大 10 分に短縮されます。

まとめ

Chrome 146 で Stable 化された DBSC を Rust(Axum + tower-sessions)で実装しました。実装してわかったことは以下のとおりです。

  • フレームワークが管理するセッション Cookie に DBSC を組み込むなら、DBSC 専用の Cookie を別途追加する方式が扱いやすい
  • Registration POST で SameSite=Lax Cookie が除外される問題は、JS リダイレクトで initiator をリセットすることで解決できる(Issue #106
  • session config の attributesMax-Age を含めるとエラーになるなど、仕様に明記されていない Chrome 固有の制約がある
  • DBSC リクエストは DevTools に表示されないchrome://net-export でも確認できるが量が膨大なので、サーバー側ログが一番手っ取り早い
  • Refresh の成功レスポンスに Secure-Session-Challenge を含めないと、Chrome が cached challenge で Phase 1 をスキップした際に nonce 照合に失敗する

DBSC はまだ Chrome (Windows) のみの対応ですが、DBSC 自体が "additive and non-disruptive"(既存のセッション管理に追加するだけで、非対応ブラウザには影響しない)という設計思想なので、導入しやすいと思います。