DPoPのブラウザ実装
ここ3年で3回DPoPを実装していていい感じなのですが、誰かに説明するのに毎度OpenID Connect (OIDC)の仕様やcrypto系のAPIを参照して資料を作っていると時間がかかるので記事を書くことにしました。ただ仕様とかAPIは毎年のように新しくなるので調査はしたほうがいいです。
DPoPを知る #link
とりあえず次の仕様と解説は必読です。
これより良い解説はないんじゃないでしょうか。
ブラウザで動くアプリの場合は次の手順でサーバーにDPoPヘッダー付きリクエストを送ります。
ブラウザは公開鍵・秘密鍵の鍵ペアを生成して保管します
ブラウザは公開鍵をトークンリクエストに乗せます
認可サーバーは公開鍵のハッシュ値
jkt
を保管しますブラウザはアクセストークンをもらいます
ブラウザはDPoPヘッダー付きリクエストをリソースサーバーに送ります
ヘッダーに公開鍵、ペイロードにメソッドとエンドポイントのURLを詰めて先の秘密鍵で署名したJWT (DPoP proof JWT) をつくります
DPoP: <作ったJWT>
というヘッダーを追加してリクエストを送りますリソースサーバーはアクセストークンとJWTの署名を検証してOKならレスポンスを返します
DPoPは何を解決しているのか #link
アクセストークンのみでアクセスを制限している場合はアクセストークンを盗めば持ち主になりすませてしまいますが、DPoPを要求するとアクセストークンだけではなりすましできなくなります。ただし、トークンの持ち主以外がDPoPヘッダーを作れてしまう場合は意味がありません。以下でこの点について考察します。
アクセストークンの持ち主以外がDPoPヘッダーを作れるか #link
アクセストークンを盗んだ側の視点で考えてみます。
盗: DPoPヘッダーを要求された。DPoPヘッダーは「ヘッダーに公開鍵、ペイロードにメソッドとエンドポイントのURLを詰めて先の秘密鍵で署名したJWT」 らしい。
盗: ペイロード部分はリクエストのエンドポイント情報なので問題ない。
盗: ヘッダーに詰める公開鍵と署名に使う秘密鍵、つまり鍵ペアが必要だ。
盗: この鍵ペアはアクセストークンの発行に使ったものと同じじゃないといけない。
盗: アクセストークンの持ち主から鍵ペアを盗めればいける。
これで「アクセストークンの持ち主以外がDPoPヘッダーを作れるか」は「アクセストークンの持ち主の鍵ペアを盗めるか」という問題になりました。
アクセストークンの持ち主の鍵ペアを盗めるか #link
以下がブラウザで鍵ペアを作るコードの例です。
const algorithm: EcKeyGenParams = {name: 'ECDSA', namedCurve: 'P-256'};
const extractable = false;
const keyUsages: Array<KeyUsage> = ['sign'];
const keyPair = await crypto.subtle.generateKey(
algorithm,
extractable,
keyUsages,
);
以下で実際に鍵ペアを作れます。それぞれExportKeyもクリックして鍵データの出力を試してみてください。
crypto.subtle.generateKey
の2番目の引数extractable
をfalse
にするとGoogle Chromeでは "InvalidAccessError: key is not extractable" となり出力できません。つまり、extractable:false
の秘密鍵はアクセストークンの持ち主のブラウザの外に持ち出せません。
まとめ #link
Q. アクセストークンの持ち主の鍵ペアを盗めるか?
A. アクセストークンの持ち主のブラウザの外には盗み出せません。
Q. アクセストークンの持ち主以外がDPoPヘッダーを作れるか?
A. アクセストークンの持ち主の鍵ペアが持ち出せずブラウザの外では作れません。
ブラウザ側がextractable:false
を正しく実装していればXSS以外でのアクセストークン悪用を防ぐ効果が期待できます。
鍵ペアの保管方法 #link
秘密鍵をextractable:false
で作ることはわかりましたが、文字列等に変換できないその鍵をどうやってブラウザに保管するかという問題が残っています。少なくともアクセストークンの期限までは鍵ペアを保管しなければなりません。
localStorage
やsessionStorage
等のStorageは文字列しか入らないので使えませんが、IndexedDBはオブジェクトをオブジェクトのまま保管できます。
keyId: string
が引数にありますが同じ鍵ペアを参照したいので基本的にはapp
等の固定値を渡すことになると思います。
const openDB = async () => new Promise<IDBDatabase>((resolve, reject) => {
const request = indexedDB.open('KeyPairTest', 1);
request.onerror = reject;
request.onupgradeneeded = () => {
const db = event.target.result;
db.createObjectStore('keyPair', { keyPath: 'keyId' });
};
request.onsuccess = (event) => {
resolve(event.target.result);
};
});
const storeKeyPair = async (keyId: string, keyPair: CryptoKeyPair) => {
const db = await openDB();
await new Promise((resolve, reject) => {
const transaction = db.transaction(['keyPair'], 'readwrite');
transaction.onerror = reject;
transaction.oncomplete = resolve;
const objectStore = transaction.objectStore('keyPair');
objectStore.put({keyId, ...keyPair});
transaction.commit();
});
};
const loadKeyPair = async (keyId: string) => {
const db = await openDB();
return await new Promise<CryptoKeyPair>((resolve, reject) => {
const transaction = db.transaction(['keyPair'], 'readonly');
const objectStore = transaction.objectStore('keyPair');
const getRequest = objectStore.get(keyId);
getRequest.onerror = reject;
getRequest.onsuccess = () => resolve(getRequest.result);
transaction.commit();
});
};
まとめ #link
秘密鍵を
extractable=false
で作るとcrypto.subtle.exportKey
できないcrypto.subtle.exportKey
できないとブラウザの外に鍵を持ち出せないブラウザの外に鍵を持ち出せないとDPoPヘッダーをブラウザ外で作れない
DPoPヘッダーをブラウザ外で作れないとブラウザ外からのなりすましを防げる
秘密鍵はIndexedDBに保管できる
ページそのもののリクエストにDPoPヘッダーはつけられないのでSSRを考えるならcookieにアクセストークンを乗せてSSRはそっちで認証しつつ、Web APIエンドポイントはDPoP必須にするなどが考えられます。