Gojabako ZoneKei Ito

DPoPのブラウザ実装

に公開に更新)履歴 (5)

ここ3年で3回DPoPを実装していていい感じなのですが、誰かに説明するのに毎度OpenID Connect (OIDC)の仕様やcrypto系のAPIを参照して資料を作っていると時間がかかるので記事を書くことにしました。ただ仕様とかAPIは毎年のように新しくなるので調査はしたほうがいいです。

## DPoPを知る

とりあえず次の仕様と解説は必読です。

これより良い解説はないんじゃないでしょうか。

ブラウザで動くアプリの場合は次の手順でDPoPヘッダー付きリクエストを送ります。

  1. ブラウザは公開鍵・秘密鍵の鍵ペアを生成して保管します
  2. ブラウザは公開鍵をトークンリクエストに乗せます
    1. 認可サーバーは公開鍵のハッシュ値jktを保管します
    2. ブラウザはアクセストークンをもらいます
  3. ブラウザはDPoPヘッダー付きリクエストをリソースサーバーに送ります
    1. ヘッダーに公開鍵、ペイロードにメソッドとエンドポイントのURLを詰めて先の秘密鍵で署名したJWT (DPoP proof JWT) をつくります
    2. DPoP: <作ったJWT>というヘッダーを追加してリクエストを送ります
    3. リソースサーバーはアクセストークンとJWTの署名を検証してOKならレスポンスを返します

## DPoPは何を解決しているのか

アクセストークンのみでアクセスを制限している場合はアクセストークンを盗めば持ち主になりすませてしまいますが、DPoPを要求するとアクセストークンだけではなりすましできなくなります。ただし、トークンの持ち主以外がDPoPヘッダーを作れてしまう場合は意味がありません。以下でこの点について考察します。

### アクセストークンの持ち主以外がDPoPヘッダーを作れるか

アクセストークンを盗んだ側の視点で考えてみます。

これで「アクセストークンの持ち主以外がDPoPヘッダーを作れるか」は「アクセストークンの持ち主の鍵ペアを盗めるか」という問題になりました。

### アクセストークンの持ち主の鍵ペアを盗めるか

以下がブラウザで鍵ペアを作るコードの例です。

#code1
1const algorithm: EcKeyGenParams = { name: 'ECDSA', namedCurve: 'P-256' };2const extractable = false;3const keyUsages: Array<KeyUsage> = ['sign'];4const keyPair = await crypto.subtle.generateKey(5 algorithm,6 extractable,7 keyUsages,8);

以下のボタンをクリックすると実際に鍵ペアを作れます。それぞれExportKeyもクリックして鍵データの出力を試してみてください。

crypto.subtle.generateKeyの2番目の引数extractablefalseにするとGoogle Chromeでは "InvalidAccessError: key is not extractable" となり出力できません。つまり、extractable:falseの秘密鍵はアクセストークンの持ち主のブラウザの外に持ち出せません。

### まとめ

Q. アクセストークンの持ち主の鍵ペアを盗めるか?
A. アクセストークンの持ち主のブラウザの外には盗み出せません。

Q. アクセストークンの持ち主以外がDPoPヘッダーを作れるか?
A. アクセストークンの持ち主の鍵ペアが持ち出せずブラウザの外では作れません。

ブラウザがextractable:falseを正しく実装していればXSS以外でアクセストークン悪用を防ぐ効果が期待できます。

## 鍵ペアの保管方法

秘密鍵をextractable:falseで作ることはわかりましたが、文字列等に変換できないその鍵をどうやってブラウザに保管するかという問題が残っています。

localStoragesessionStorage等のStorageは文字列しか入らないので使えませんが、IndexedDBはオブジェクトをオブジェクトのまま保管できます。

鍵ペアをIndexedDBに保管します。

keyIdが引数にありますが、同じ鍵ペアを参照したいので基本的にはapp等の固定値を渡すことになると思います。

#code2
1const openDB = async () =>2 new Promise<IDBDatabase>((resolve, reject) => {3 const request = indexedDB.open('KeyPairTest', 1);4 request.onerror = reject;5 request.onupgradeneeded = () => {6 const db = event.target.result;7 db.createObjectStore('keyPair', { keyPath: 'keyId' });8 };9 request.onsuccess = (event) => {10 resolve(event.target.result);11 };12 });1314const storeKeyPair = async (keyId: string, keyPair: CryptoKeyPair) => {15 const db = await openDB();16 await new Promise((resolve, reject) => {17 const transaction = db.transaction(['keyPair'], 'readwrite');18 transaction.onerror = reject;19 transaction.oncomplete = resolve;20 const objectStore = transaction.objectStore('keyPair');21 objectStore.put({ keyId, ...keyPair });22 transaction.commit();23 });24};2526const loadKeyPair = async (keyId: string) => {27 const db = await openDB();28 return await new Promise<CryptoKeyPair>((resolve, reject) => {29 const transaction = db.transaction(['keyPair'], 'readonly');30 const objectStore = transaction.objectStore('keyPair');31 const getRequest = objectStore.get(keyId);32 getRequest.onerror = reject;33 getRequest.onsuccess = () => resolve(getRequest.result);34 transaction.commit();35 });36};

## まとめ

  1. 秘密鍵をextractable=falseで作るとcrypto.subtle.exportKeyできない
  2. crypto.subtle.exportKeyできないとブラウザの外に鍵を持ち出せない
  3. ブラウザの外に鍵を持ち出せないとDPoPヘッダーをブラウザ外で作れない
  4. DPoPヘッダーをブラウザ外で作れないとブラウザ外からのなりすましを防げる
  5. 秘密鍵はIndexedDBに保管できる

ページそのもののリクエストにDPoPヘッダーはつけられないのでSSRを考えるならcookieにアクセストークンを乗せてSSRはそっちで認証しつつ、Web APIエンドポイントはDPoP必須にするなどが考えられます。

## 参考資料