DPoPのブラウザ実装
ここ3年で3回DPoPを実装していていい感じなのですが、誰かに説明するのに毎度OpenID Connect (OIDC)の仕様やcrypto系のAPIを参照して資料を作っていると時間がかかるので記事を書くことにしました。ただ仕様とかAPIは毎年のように新しくなるので調査はしたほうがいいです。
## DPoPを知る
とりあえず次の仕様と解説は必読です。
- OAuth 2.0 Demonstrating Proof-of-Possession at the Application Layer (DPoP)
- 図解 DPoP (OAuth アクセストークンのセキュリティ向上策の一つ)
これより良い解説はないんじゃないでしょうか。
ブラウザで動くアプリの場合は次の手順でDPoPヘッダー付きリクエストを送ります。
- ブラウザは公開鍵・秘密鍵の鍵ペアを生成して保管します
- ブラウザは公開鍵をトークンリクエストに乗せます
- 認可サーバーは公開鍵のハッシュ値
jkt
を保管します - ブラウザはアクセストークンをもらいます
- 認可サーバーは公開鍵のハッシュ値
- ブラウザはDPoPヘッダー付きリクエストをリソースサーバーに送ります
- ヘッダーに公開鍵、ペイロードにメソッドとエンドポイントのURLを詰めて先の秘密鍵で署名したJWT (DPoP proof JWT) をつくります
DPoP: <作ったJWT>
というヘッダーを追加してリクエストを送ります- リソースサーバーはアクセストークンとJWTの署名を検証してOKならレスポンスを返します
## DPoPは何を解決しているのか
アクセストークンのみでアクセスを制限している場合はアクセストークンを盗めば持ち主になりすませてしまいますが、DPoPを要求するとアクセストークンだけではなりすましできなくなります。ただし、トークンの持ち主以外がDPoPヘッダーを作れてしまう場合は意味がありません。以下でこの点について考察します。
### アクセストークンの持ち主以外がDPoPヘッダーを作れるか
アクセストークンを盗んだ側の視点で考えてみます。
- 盗: DPoPヘッダーを要求された。DPoPヘッダーは「ヘッダーに公開鍵、ペイロードにメソッドとエンドポイントのURLを詰めて先の秘密鍵で署名したJWT」 らしい。
- 盗: ペイロード部分はリクエストのエンドポイント情報なので問題ない。
- 盗: ヘッダーに詰める公開鍵と署名に使う秘密鍵、つまり鍵ペアが必要だ。
- 盗: この鍵ペアはアクセストークンの発行に使ったものと同じじゃないといけない。
- 盗: アクセストークンの持ち主から鍵ペアを盗めればいける。
これで「アクセストークンの持ち主以外がDPoPヘッダーを作れるか」は「アクセストークンの持ち主の鍵ペアを盗めるか」という問題になりました。
### アクセストークンの持ち主の鍵ペアを盗めるか
以下がブラウザで鍵ペアを作るコードの例です。
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);
以下のボタンをクリックすると実際に鍵ペアを作れます。
crypto.subtle.generateKey
の2番目の引数extractable
をfalse
にするとGoogle Chromeでは "InvalidAccessError: key is not extractable" となり出力できません。つまり、extractable:false
の秘密鍵はアクセストークンの持ち主のブラウザの外に持ち出せません。
### まとめ
Q. アクセストークンの持ち主の鍵ペアを盗めるか?
A. アクセストークンの持ち主のブラウザの外には盗み出せません。
Q. アクセストークンの持ち主以外がDPoPヘッダーを作れるか?
A. アクセストークンの持ち主の鍵ペアが持ち出せずブラウザの外では作れません。
ブラウザがextractable:false
を正しく実装していればXSS以外でアクセストークン悪用を防ぐ効果が期待できます。
## 鍵ペアの保管方法
秘密鍵をextractable:false
で作ることはわかりましたが、文字列等に変換できないその鍵をどうやってブラウザに保管するかという問題が残っています。
localStorage
やsessionStorage
等のStorageは文字列しか入らないので使えませんが、IndexedDBはオブジェクトをオブジェクトのまま保管できます。
keyId
が引数にありますが、同じ鍵ペアを参照したいので基本的にはapp
等の固定値を渡すことになると思います。
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};
## まとめ
- 秘密鍵を
extractable=false
で作るとcrypto.subtle.exportKey
できない crypto.subtle.exportKey
できないとブラウザの外に鍵を持ち出せない- ブラウザの外に鍵を持ち出せないとDPoPヘッダーをブラウザ外で作れない
- DPoPヘッダーをブラウザ外で作れないとブラウザ外からのなりすましを防げる
- 秘密鍵はIndexedDBに保管できる
ページそのもののリクエストにDPoPヘッダーはつけられないのでSSRを考えるならcookieにアクセストークンを乗せてSSRはそっちで認証しつつ、Web APIエンドポイントはDPoP必須にするなどが考えられます。