DPoPのブラウザ実装

公開 ( 更新)履歴 (9)

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

DPoPを知る #link

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

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

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

  1. ブラウザは公開鍵・秘密鍵の鍵ペアを生成して保管します

  2. ブラウザは公開鍵をトークンリクエストに乗せます

    1. 認可サーバーは公開鍵のハッシュ値jktを保管します

    2. ブラウザはアクセストークンをもらいます

  3. ブラウザはDPoPヘッダー付きリクエストをリソースサーバーに送ります

    1. ヘッダーに公開鍵、ペイロードにメソッドとエンドポイントのURLを詰めて先の秘密鍵で署名したJWT (DPoP proof JWT) をつくります

    2. DPoP: <作ったJWT> というヘッダーを追加してリクエストを送ります

    3. リソースサーバーはアクセストークンとJWTの署名を検証してOKならレスポンスを返します

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

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

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

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

盗: DPoPヘッダーを要求された。DPoPヘッダーは「ヘッダーに公開鍵、ペイロードにメソッドとエンドポイントのURLを詰めて先の秘密鍵で署名したJWT」 らしい。
盗: ペイロード部分はリクエストのエンドポイント情報なので問題ない。
盗: ヘッダーに詰める公開鍵と署名に使う秘密鍵、つまり鍵ペアが必要だ。
盗: この鍵ペアはアクセストークンの発行に使ったものと同じじゃないといけない。
盗: アクセストークンの持ち主から鍵ペアを盗めればいける。

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

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

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

  1. const algorithm: EcKeyGenParams = {name: 'ECDSA', namedCurve: 'P-256'};
  2. const extractable = false;
  3. const keyUsages: Array<KeyUsage> = ['sign'];
  4. const 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の秘密鍵はアクセストークンの持ち主のブラウザの外に持ち出せません。

まとめ #link

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

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

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

鍵ペアの保管方法 #link

秘密鍵をextractable:falseで作ることはわかりましたが、文字列等に変換できないその鍵をどうやってブラウザに保管するかという問題が残っています。少なくともアクセストークンの期限までは鍵ペアを保管しなければなりません。

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

鍵ペアをIndexedDBに保管する

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

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

まとめ #link

  1. 秘密鍵をextractable=falseで作るとcrypto.subtle.exportKeyできない

  2. crypto.subtle.exportKeyできないとブラウザの外に鍵を持ち出せない

  3. ブラウザの外に鍵を持ち出せないとDPoPヘッダーをブラウザ外で作れない

  4. DPoPヘッダーをブラウザ外で作れないとブラウザ外からのなりすましを防げる

  5. 秘密鍵はIndexedDBに保管できる

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

参考資料 #link