oauth · L7

# DPoP：把 token 綁在用戶端的鑰匙上

Chance Lu

**一句話**：DPoP 讓 API 通行證只有原主人用得了。每次拿通行證打 API，還得用一把**只有你手上有的鑰匙**，當場替「這一次」的請求蓋章。別人就算偷到通行證，沒有鑰匙也蓋不出章，通行證在他手上就沒用。

> 比喻：一般的通行證像**演唱會門票**，誰拿著都能進場。DPoP 像**記名機票**，登機時要出示護照，而且護照要跟機票上的名字對得上。

## 總覽：通行證被偷了，會發生什麼事

① Bearer：撿到就能用
  用戶端合法拿到 token
  API只看 token → 照收
  攻擊者
  token
  外洩（log、代理、被偷）
  拿 token 打 API

  ② DPoP：token ＋ 每次當場簽的 proof
  用戶端私鑰只在這裡
  API缺 proof → 拒絕
  攻擊者
  token ＋ proof
  只偷到 token
  沒私鑰，做不出 proof

  ③ DPoP 的極限：壞程式已經在用戶端裡執行
  用戶端裡面跑著壞程式（例如 XSS）
  APIproof 是真的 → 照收
  token ＋ 壞程式當場借用私鑰簽的 proof

點圖可放大。proof＝用只有你有的私鑰，對這一次請求當場簽出來的一小段資料（下一節定義）。XSS＝攻擊者把自己的 JavaScript 塞進你的網頁裡執行。

> DPoP 讓「偷到 token」不等於「能用 token」；但壞程式已經在用戶端裡面時，它擋不住（RFC 9449 §11.4）。

## 先認識 10 個名詞

- 用戶端（client）

    - 代替使用者去打 API 的程式，例如網頁前端或手機 App。

- 授權伺服器（AS）／資源伺服器（RS）

    - AS（authorization server）負責發 token；RS（resource server）就是你要打的 API，負責收 token、決定給不給資料。

- Access token（存取權杖）

    - AS 發給用戶端的一串字，就是上面說的「API 通行證」。打 API 時拿出來，代表「我有權限」。

- Bearer token（持有者權杖）

    - 最常見的 access token 用法。RFC 6750 的定義是：**任何持有它的人**都能用它，不必證明自己握有任何金鑰。寫法是 `Authorization: Bearer <token>`。

- 公鑰／私鑰

    - 成對產生的兩把鑰匙。私鑰自己藏好，公鑰可以公開給任何人。

- 簽章（signature）

    - 用私鑰對一段資料算出的一串值。任何人都能拿公鑰驗證「這段資料確實是私鑰主人簽的、而且沒被改過」，但沒有私鑰就偽造不出來。

- JWT（JSON Web Token，RFC 7519）

    - 一種裝資料的格式：`header.payload.signature` 三段，各段用 base64url 編碼，中間用點隔開。payload 裡的每個欄位叫一個 **claim**。

- JWK（JSON Web Key，RFC 7517）

    - 用 JSON 表示一把金鑰，例如 `{"kty":"EC","crv":"P-256","x":"…","y":"…"}`。

- 雜湊／指紋（hash／thumbprint）

    - 把任意資料丟進 SHA-256 之類的函式，得到固定長度的一串值。同樣的輸入一定得到同樣的值，但沒辦法倒推回原文。對一把公鑰算出來的雜湊，就叫這把鑰匙的「指紋」。

- DPoP proof

    - 用私鑰對**這一次**請求簽出來的一個小 JWT，放在 HTTP 標頭 `DPoP:` 裡送出。每個請求都要附一張新的。

> 記住一句就好：**簽章證明「是私鑰主人做的」，指紋拿來比對「是不是同一把鑰匙」。**

## 為什麼需要它：Bearer token 的弱點

**Bearer token 把「有權限」等同於「手上有這串字」**。token 一旦外洩，例如被寫進 log、被惡意代理攔下、被同機器上的其他程式讀走，別人就能原樣拿去用，API 也分辨不出來。

DPoP 把 token 綁在用戶端的金鑰上：每個請求都要附一張 proof。攻擊者只偷到 token、沒有私鑰，就做不出 proof。

> Bearer 只問「你有沒有 token」；DPoP 還要問「你有沒有那把私鑰」（RFC 9700 §2.2.1 建議這類綁定發送者的 token）。

## 運作流程：從拿 token 到打 API

用戶端
  AS
  RS（API）

  ① 產生一對金鑰（私鑰不離開裝置）

  ② POST /tokenDPoP 標頭：簽了「POST /token」的 proof

  ③ 驗 proof、算出公鑰指紋　token 是 JWT → 在裡面加 cnf.jkt 欄位　token 不是 JWT → AS 自己記下來

  ④ access token（token\_type: DPoP）

  ⑤ GET /protectedresource　Authorization: DPoP &lt;token&gt;DPoP 標頭：另簽一張「GET 這個網址 ＋ 這顆 token」的新 proof

  ⑥ 驗 proof，比對 method／URL／時間　比對 token 雜湊、比對公鑰指紋

  ⑦ 200 OK（任一項不符 → 401）

點圖可放大。/token＝向 AS 換 token 的 API；之前的登入／授權步驟不在本篇範圍。每個請求都要一張全新的 proof（RFC 9449 §4）。

> 拿 token 時簽一張 proof，讓 AS 記住你的公鑰；之後每打一次 API 再簽一張，證明私鑰還在你手上。

## proof 怎麼把 token 和這次請求綁在一起

下面是 RFC 9449 範例 proof（§4.2、§7.1）解碼後的樣子：

```
// header
{
  "typ": "dpop+jwt",   // 宣告「我是 DPoP proof」，不是一般 JWT
  "alg": "ES256",      // 必須是公私鑰那一類演算法；不能用 none（根本不簽）
                       // 或 HMAC（雙方共用同一個密碼來簽，就證明不了「只有你有」）
  "jwk": {             // 用戶端的公鑰（絕不能含私鑰）
    "kty": "EC", "crv": "P-256",
    "x": "l8tFrhx-34tV3hRICRDY9zCkDlpBhF42UQUfWVAWBFs",
    "y": "9VE4jf_Ok_o64zbTTlcuNJajHmt6v9TDVrU0CdvGRDA"
  }
}
// payload（每個欄位綁什麼，看下圖）
{
  "jti": "e1j3V_bKic8-LAEB",
  "htm": "GET",
  "htu": "https://resource.example.org/protectedresource",
  "iat": 1562262618,
  "ath": "fUHyO2r2Z3DZ53EsNrWBb0xWXoaNy59IiKCAqksmQEo"
}
// signature：用私鑰簽 header.payload
```

DPoP proof
    header.jwk（公鑰）
    payload.ath
    payload.htm ＋ htu
    payload.jti ＋ iat
    → 防同一張 proof 被再用一次

    access token
    cnf.jkt = 公鑰指紋
    AS 發 token 時寫進去
  
    這一次 HTTP 請求
    GET https://resource.example.org
        /protectedresource
  算指紋，要等於 jkt
  token 雜湊要相符
  method、URL 要相符

點圖可放大。proof 用三條線把「金鑰、token、這次請求」綁在一起；`htu` 比對時忽略 query 和 fragment。

公鑰指紋怎麼算（RFC 7638）

只取這種金鑰的**必要欄位**（EC 金鑰是 `crv`、`kty`、`x`、`y`），按字母順序排好、不留空白，得到一段 JSON。再對它做 SHA-256，最後轉成 base64url。上面範例公鑰算出來是 `0ZcOCORZNYy-DWpqq30jZyJGHTN0d2HglBV3uiguA4I`，跟 RFC 9449 §6.1 範例 token 的 `jkt` 一致（我自己跑過驗證）。`cnf` 是 RFC 7800 定義的 claim，意思是「出示這個 token 的人，應該握有這把鑰匙」。

送出時，標頭跟 Bearer 只差兩處：方案名改成 `DPoP`，另外多帶一個 `DPoP` 標頭放 proof。

```
# Bearer（RFC 6750）
GET /protectedresource HTTP/1.1
Host: resource.example.org
Authorization: Bearer Kz~8mXK1EalYznwH-LC-1fBAo.4Ljp~zsPE_NeO.gxU

# DPoP（RFC 9449）
GET /protectedresource HTTP/1.1
Host: resource.example.org
Authorization: DPoP Kz~8mXK1EalYznwH-LC-1fBAo.4Ljp~zsPE_NeO.gxU
DPoP: eyJ0eXAiOiJkcG9wK2p3dCIsImFsZyI6IkVTMjU2IiwiandrIjp7…
```

RS **必須拒絕**用 `Bearer` 方案送來的 DPoP 綁定 token（RFC 9449 §7.2），不然攻擊者把方案名改回 Bearer、不附 proof，就能繞過整套機制。

> proof 綁住三樣東西：哪把鑰匙、哪顆 token、哪一次請求。三樣都對得上，RS 才放行。

## RS 怎麼驗

上圖的三條線，RS 都要逐一比對。除此之外，RFC 9449 §4.2–4.3 還要求幾項格式檢查：

- 請求裡只能有**一個** `DPoP` 標頭。
- `typ` 必須是 `dpop+jwt`。
- `alg` 必須是公私鑰那一類演算法，而且用 `jwk` 驗得過。
- `jwk` 裡不能含私鑰。
- 必要的 claim 都要在：`jti`、`htm`、`htu`、`iat`；打 RS 時還要加上 `ath`。

驗不過時回 `401`，原因寫在 `WWW-Authenticate` 標頭裡，分兩種：

- **proof 本身不合格**（格式錯、用公鑰驗不過、method／URL 對不上）：`WWW-Authenticate: DPoP error="invalid_dpop_proof"`
- **token 綁的鑰匙和 proof 的鑰匙對不上**：`WWW-Authenticate: DPoP error="invalid_token"`（RFC 範例的說明文字是「Invalid DPoP key binding」）

另外兩件事一句帶過：

- **請求被截下來原封不動重送**：靠 `iat` 的時間窗（伺服器只收剛簽好不久的 proof，RFC 建議以秒到分鐘計）擋掉，再加上同一個 `jti` 只收一次（§11.1）。伺服器還能要求 proof 帶上它給的值（nonce），細節另篇再談。
- **body 被竄改**：proof 只簽 method 和 URL，不包含 body，所以 body 完整性仍要靠 TLS（§11.7）。

> RS 先確認 proof 格式正確，再比對三條綁定線；格式錯回 `invalid_dpop_proof`，鑰匙對不上回 `invalid_token`。

## 動手跑：用 Node 內建模組產 proof 並自己驗

只用 Node 內建的 `node:crypto`，不用裝任何套件。存成 `dpop.mjs` 後執行 `node dpop.mjs`：

```
import { generateKeyPairSync, sign, verify, createHash, randomUUID, createPublicKey } from "node:crypto";

const b64u = (buf) => Buffer.from(buf).toString("base64url");
const sha256 = (s) => createHash("sha256").update(s).digest();

// 1) 用戶端產一把 P-256 金鑰（私鑰留在本機，公鑰放進 proof）
const { privateKey, publicKey } = generateKeyPairSync("ec", { namedCurve: "P-256" });
const { kty, crv, x, y } = publicKey.export({ format: "jwk" });
const jwk = { kty, crv, x, y };

// 2) 公鑰指紋 jkt（RFC 7638：必要欄位、字母順序、無空白 → SHA-256 → base64url）
const jkt = b64u(sha256(JSON.stringify({ crv, kty, x, y })));

// 3) 打 API 用的 proof
function makeProof(htm, htu, accessToken) {
  const header = { typ: "dpop+jwt", alg: "ES256", jwk };
  const payload = { jti: randomUUID(), htm, htu, iat: Math.floor(Date.now() / 1000) };
  if (accessToken) payload.ath = b64u(sha256(accessToken));
  const input = `${b64u(JSON.stringify(header))}.${b64u(JSON.stringify(payload))}`;
  const sig = sign("sha256", Buffer.from(input), { key: privateKey, dsaEncoding: "ieee-p1363" });
  return `${input}.${b64u(sig)}`;
}

const url = "https://resource.example.org/protectedresource";
const token = "Kz~8mXK1EalYznwH-LC-1fBAo.4Ljp~zsPE_NeO.gxU"; // RFC 9449 範例 token
const proof = makeProof("GET", url, token);

// 4) 模擬 RS 驗證（節錄關鍵幾步）
const [h, p, s] = proof.split(".");
const H = JSON.parse(Buffer.from(h, "base64url")), P = JSON.parse(Buffer.from(p, "base64url"));
const key = createPublicKey({ key: H.jwk, format: "jwk" });
console.log({
  typ: H.typ === "dpop+jwt",
  signature: verify("sha256", Buffer.from(`${h}.${p}`), { key, dsaEncoding: "ieee-p1363" }, Buffer.from(s, "base64url")),
  htm: P.htm === "GET",
  htu: P.htu === url,
  iat: Math.abs(Date.now() / 1000 - P.iat) < 60,
  ath: P.ath === b64u(sha256(token)),   // 會得到 fUHyO2r2…（跟 RFC 範例一樣）
  jkt: b64u(sha256(JSON.stringify({ crv: H.jwk.crv, kty: H.jwk.kty, x: H.jwk.x, y: H.jwk.y }))) === jkt,
});
```

實際執行結果：七項全部是 `true`。你可以試著改一下 `htu` 或 token，再看看哪一項變成 `false`。ES256 簽章要指定 `dsaEncoding: "ieee-p1363"`，否則 JWT 驗不過。

> 整套機制就是三步：產金鑰、每個請求簽一張 proof、RS 逐項比對。正式環境請用成熟的函式庫。refresh token 也能綁在同一把鑰匙上，另篇再談。

## 參考來源

- [RFC 9449 — OAuth 2.0 Demonstrating Proof of Possession (DPoP)](https://www.rfc-editor.org/rfc/rfc9449.html)（proof §4、token 綁定 §5–6、資源請求 §7、安全考量 §11）
- [RFC 6750 — Bearer Token Usage](https://www.rfc-editor.org/rfc/rfc6750.html)
- [RFC 7638 — JWK Thumbprint](https://www.rfc-editor.org/rfc/rfc7638.html)
- [RFC 7800 — Proof-of-Possession Key Semantics for JWTs（cnf claim）](https://www.rfc-editor.org/rfc/rfc7800.html)
- [RFC 7519 — JSON Web Token](https://www.rfc-editor.org/rfc/rfc7519.html)／[RFC 7517 — JSON Web Key](https://www.rfc-editor.org/rfc/rfc7517.html)
- [RFC 9700 — OAuth 2.0 Security Best Current Practice](https://www.rfc-editor.org/rfc/rfc9700.html)（§2.2.1）
