oauth · L7

# DPoP 進階：nonce 與 refresh token

Chance Lu

**先回顧**：[第一篇](/n/dpop-把-token-綁在用戶端的鑰匙上?from=oauth)講過，DPoP 讓用戶端每個請求都附一張用私鑰當場簽的 proof。token 透過 `cnf.jkt` 記住公鑰指紋，proof 則用 `ath`、`htm`／`htu` 綁住這顆 token 和這一次請求。

**這篇補兩個洞**：一是攻擊者「事先」簽好的 proof 怎麼擋，二是 refresh token 被偷了怎麼辦。

## 總覽：三種攻擊，三道防線

① 截到請求，原封不動重送　→　擋法：時間窗＋jti 只收一次（第一篇）
  攻擊者截到一個完整請求
  RSjti 看過了 → 拒絕
  同一張 proof 再送一次

  ② 預先產生 proof　→　擋法：server nonce（本篇上半）
  攻擊者曾短暫控制用戶端
  RSnonce 對不上 → 拒絕
  事先簽好、時間填未來的 proof

  ③ refresh token 被偷　→　擋法：refresh token 綁鑰匙（本篇下半）
  攻擊者偷到 refresh token
  AS鑰匙指紋不同 → 拒絕
  refresh token ＋ 用自己鑰匙簽的 proof

點圖可放大。① 第一篇已經處理過；② 和 ③ 是這篇的主角。

> 三種攻擊各有一道主要的擋法。不過 ② 的 nonce 也會回頭保護 ③：攻擊者如果連打 /token 用的 proof 都事先簽好了，就要靠 AS 的 nonce 擋下來（見下半）。

## 這篇的 5 個新名詞

- Nonce

    - 伺服器發出的一個臨時值，別人猜不到。用戶端要把它簽進 proof，藉此證明這張 proof 是拿到這個值**之後**才簽的。名字來自 number used once（只用一次的數），但在 DPoP 裡，同一個 nonce 其實可以重複使用（RFC 9449 §11.1）。

- 預先產生 proof（pre-generation）

    - 攻擊者曾經短暫控制用戶端，例如在網頁裡跑了幾分鐘的惡意程式碼。他趁那段時間用私鑰簽好一批 proof，把 `iat` 填成未來的時間，然後帶走。之後他就算再也碰不到私鑰，到了那些時間點，這些 proof 還是能拿出來用（§11.2）。

- Refresh token（更新權杖）

    - AS 常會連同 access token 一起發的另一串字。access token 過期時，用戶端拿它去向 AS 換新的。它只會送給 AS，不會送給 RS（RFC 6749 §1.5）。因為它能一直換出新的 access token，被偷的代價也比較大。

- 用戶端憑證（client credentials）

    - App 本身向 AS 證明「我是哪個 App」用的憑據，例如一組 client secret。它跟使用者的登入密碼無關。

- 公開用戶端／機密用戶端（public／confidential client）

    - 公開用戶端跑在使用者的裝置上，例如瀏覽器裡的前端或手機 App，**藏不住**用戶端憑證。機密用戶端跑在受控的伺服器上，**能安全保管**用戶端憑證，並用它向 AS 驗明身分（RFC 6749 §2.1）。

> nonce 證明 proof「簽得夠晚」；refresh token 是能一直換出新 access token 的「長期票」。

## 上半：server nonce

### 為什麼攻擊者要「事先簽好帶走」

- 私鑰通常會設成**不能匯出**。壞程式就算跑在用戶端裡，也只能當場叫私鑰簽名，沒辦法把鑰匙本身帶走（§11.4）。所以它的最佳策略，就是趁還在的時候多簽幾張 proof 帶走。
- 攻擊者也可能是**使用者本人**。RFC 舉的例子是：員工在公司電腦上預先簽好 proof，複製到別處使用，藉此繞過稽核（§11.2）。
- 打 RS 的 proof 有 `ath`，所以預先簽好的 proof 最多只能用到那顆 access token 過期為止（§11.2）。但打 /token 的 proof 不出示 access token，所以沒有 `ath`（§4.2），也就沒有這個期限。這一點下半會再回來談。

### nonce 擋什麼、不擋什麼

**預先產生的 proof：jti 和 iat 都擋不住，nonce 擋得住。**每張 proof 的 `jti` 都不同，伺服器從沒看過；`iat` 又剛好落在使用的時間點附近，時間窗也會放行。nonce 是伺服器**事後**才發的，而且猜不到，攻擊者當初簽的時候不可能知道（§11.2）。

**原封重送：主要靠時間窗加 jti 擋。**nonce 能把可重送的時間再縮短一些（§11.1）。但同一個 nonce 可以重複使用，所以單靠 nonce 擋不掉同一張 proof 被重送。

> nonce 獨有的作用，是擋「預先產生的 proof」。

### 握手流程：被拒 → 拿到 nonce → 重簽 → 放行

nonce 不是每台伺服器都必須啟用，要不要用由伺服器自己決定（§8 用的是 MAY）。但只要它發過 nonce 給某個用戶端，之後就**不能**再接受那個用戶端不帶 nonce 的 proof（§11.3），否則攻擊者只要把 nonce 拿掉就能繞過。

用戶端
  RS（或 AS）

  ① GET /protectedresource ＋ proof（沒有 nonce）

  ② 401　WWW-Authenticate: DPoP error="use\_dpop\_nonce"DPoP-Nonce: n1

  ③ 把 "nonce": "n1" 放進 proof，重新簽

  ④ GET /protectedresource ＋ 新 proof（nonce: n1）

  ⑤ 200 OK　＋ DPoP-Nonce: n2伺服器可隨時換新值；用戶端之後的 proof 改帶 n2

點圖可放大。高亮會依序走過 ①→⑤。AS 的流程相同，只是 ② 改回 HTTP 400，`error` 放在 JSON body 裡（§8）；RS 回 401，錯誤寫在 `WWW-Authenticate` 標頭（§9）。

- **nonce 對用戶端不透明**：照抄進 proof 的 `nonce` claim 就好，不必理解內容（§8）。
- **更新 nonce**：伺服器可以像第一次那樣用錯誤回應發新值，也可以在 200 回應裡順便附上新的 `DPoP-Nonce`。用戶端在拿到新值之前，繼續用手上那個（§8.2）。
- **伺服器會容忍最近發過的幾個 nonce**，不在近期名單裡的才拒絕（§8）。
- **AS 和 RS 的 nonce 各自獨立**：兩邊發的值不同，只有發出它的那台伺服器才會接受（§9）。

> 流程很單純：被拒絕時拿到一個值，簽進 proof 重送；之後伺服器換新值，用戶端就跟著換。

## 下半：refresh token 也綁同一把鑰匙

公開用戶端
  AS

  ① POST /token ＋ proof（鑰匙 K）
  ② refresh token 綁上 K 的公鑰指紋
  ③ access token ＋ refresh token
  ④ access token 過期：POST /token（refresh token）＋ proof，必須用同一把 K 簽
  ⑤ 新的 access token

點圖可放大。攻擊者偷到 refresh token，但手上沒有 K、也沒有事先簽好的 proof，第 ④ 步就過不了（見總覽圖 ③）。

RFC 9449 §5 的規則可以濃縮成三句：

- **公開用戶端**：在 /token 出示有效 proof 時拿到的 refresh token，**必須**綁在那把公鑰上。
- **換新 token 時**：每次用這顆 refresh token，都要附 proof，而且必須是拿到它時用的**同一把鑰匙**。
- **機密用戶端**：refresh token **不綁** DPoP 公鑰。原因是它**已經用既有機制綁定發送者**：refresh token 綁在 client ID 上，每次使用都要出示用戶端憑證。RFC 另外提到這種作法的一個好處：用戶端更換用戶端憑證時，refresh token 不會跟著失效。

**綁鑰匙只擋得住「只偷到 refresh token」的人。**如果攻擊者當初控制用戶端時，還順便簽好了一批打 /token 的 proof，就能配著偷來的 refresh token 一起用。這些 proof 沒有 `ath`，不會隨 access token 過期而失效，這時就要靠 AS 的 nonce 擋下（§11.2）。

RFC 9700 §2.2.2 要求，公開用戶端的 refresh token 要嘛綁定發送者，要嘛採用 rotation。rotation 的作法是：每次換新 access token 時也換發一顆新的 refresh token，舊的作廢；舊的一被拿來用，就知道它外洩了（§4.14.2）。DPoP 綁鑰匙走的是前一條路。

> 公開用戶端藏不住用戶端憑證，所以改用鑰匙看住 refresh token；鑰匙之外，再用 AS 的 nonce 擋事先簽好的 proof。

## 動手跑：nonce 握手與 refresh token 綁定

延續第一篇的寫法，只用 Node 內建的 `node:crypto`。存成 `dpop-nonce.mjs` 後執行 `node dpop-nonce.mjs`。程式裡同時模擬了用戶端、兩台 RS（一台要求 nonce、一台不要求）和 AS：

```
// node dpop-nonce.mjs —— 只用 Node 內建模組：模擬 RS 的 server nonce，以及 AS 的 refresh token 綁定
import { generateKeyPairSync, sign, verify, createHash, randomUUID, randomBytes, createPublicKey } from "node:crypto";

const b64u = (b) => Buffer.from(b).toString("base64url");
const sha256 = (s) => createHash("sha256").update(s).digest();
const now = () => Math.floor(Date.now() / 1000);

function newClientKey() {
  const { privateKey, publicKey } = generateKeyPairSync("ec", { namedCurve: "P-256" });
  const { kty, crv, x, y } = publicKey.export({ format: "jwk" });
  return { privateKey, jwk: { kty, crv, x, y } };
}
const thumb = ({ crv, kty, x, y }) => b64u(sha256(JSON.stringify({ crv, kty, x, y })));

function makeProof(key, htm, htu, nonce) {
  const header = { typ: "dpop+jwt", alg: "ES256", jwk: key.jwk };
  const payload = { jti: randomUUID(), htm, htu, iat: now() };
  if (nonce) payload.nonce = nonce;
  const input = `${b64u(JSON.stringify(header))}.${b64u(JSON.stringify(payload))}`;
  return `${input}.${b64u(sign("sha256", Buffer.from(input), { key: key.privateKey, dsaEncoding: "ieee-p1363" }))}`;
}

// 共用：驗 proof 本身（簽章、method、URL、時間窗、jti 只收一次）
function checkProof(proof, htm, htu, seenJti) {
  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" });
  const ok = H.typ === "dpop+jwt" && P.htm === htm && P.htu === htu && Math.abs(now() - P.iat) < 60 &&
    verify("sha256", Buffer.from(`${h}.${p}`), { key, dsaEncoding: "ieee-p1363" }, Buffer.from(s, "base64url"));
  if (!ok) return { err: "invalid_dpop_proof" };
  if (seenJti.has(P.jti)) return { err: "invalid_dpop_proof（jti 重複）" };
  seenJti.add(P.jti);
  return { H, P };
}

// ---------- RS：requireNonce 決定要不要啟用 nonce ----------
const RS_URL = "https://resource.example.org/protectedresource";
function makeRS(requireNonce) {
  const srv = { recent: [], seen: new Set() };   // 保留最近幾個 nonce 都認（§8 的 recent nonces 窗）
  const issueNonce = () => { const n = b64u(randomBytes(16)); srv.recent = [n, ...srv.recent].slice(0, 5); return n; };
  return (proof) => {
    const r = checkProof(proof, "GET", RS_URL, srv.seen);
    if (r.err) return { status: 401, error: r.err };
    if (!requireNonce) return { status: 200 };
    if (!srv.recent.includes(r.P.nonce)) return { status: 401, error: "use_dpop_nonce", nonce: issueNonce() };
    return { status: 200, nonce: issueNonce() };   // 成功回應順便發新 nonce
  };
}
const laxRS = makeRS(false), rs = makeRS(true);

const alice = newClientKey();
console.log("== server nonce（RS）==");
// 攻擊者事先簽好帶走的 proof。為了示範，iat 直接填現在＝模擬「那個未來時間已經到了」
const pre = makeProof(alice, "GET", RS_URL);
let r = rs(makeProof(alice, "GET", RS_URL));
console.log("1. 沒帶 nonce                    →", r.status, r.error, "＋ DPoP-Nonce");
const n1 = r.nonce;
const ok = makeProof(alice, "GET", RS_URL, n1);
r = rs(ok);
console.log("2. 帶 nonce 重簽                 →", r.status, "＋ 新的 DPoP-Nonce");
r = laxRS(pre);
console.log("3. 預先產生的 proof → 不要 nonce 的 RS →", r.status, "（被騙過）");
r = rs(pre);
console.log("4. 同一張 → 要求 nonce 的 RS      →", r.status, r.error);
r = rs(ok);
console.log("5. 同一張 proof 原封重送          →", r.status, r.error);
r = rs(makeProof(alice, "GET", RS_URL, n1));
console.log("6. 同一個 nonce＋新 jti           →", r.status, "（nonce 可重複使用）");

// ---------- AS：refresh token 綁定（公開用戶端）----------
const TOKEN_URL = "https://server.example.com/token";
const as = { seen: new Set(), refresh: new Map() };   // refresh token → 綁定的公鑰指紋
function asToken(proof, grant) {
  const r = checkProof(proof, "POST", TOKEN_URL, as.seen);
  if (r.err) return { status: 400, error: r.err };
  const jkt = thumb(r.H.jwk);
  if (grant.refresh_token && as.refresh.get(grant.refresh_token) !== jkt)
    return { status: 400, error: "invalid_grant" };
  const rt = b64u(randomBytes(16));
  as.refresh.set(rt, jkt);
  return { status: 200, token_type: "DPoP", refresh_token: rt };
}
console.log("== refresh token 綁定（AS）==");
const first = asToken(makeProof(alice, "POST", TOKEN_URL), { code: "abc" });
console.log("7. 第一次拿 token                →", first.status, first.token_type, "＋ refresh token");
const mallory = newClientKey();
r = asToken(makeProof(mallory, "POST", TOKEN_URL), { refresh_token: first.refresh_token });
console.log("8. 偷來的 refresh token＋別的鑰匙 →", r.status, r.error);
r = asToken(makeProof(alice, "POST", TOKEN_URL), { refresh_token: first.refresh_token });
console.log("9. 原主人＋同一把鑰匙            →", r.status, r.token_type);
```

實際執行結果：

```
== server nonce（RS）==
1. 沒帶 nonce                    → 401 use_dpop_nonce ＋ DPoP-Nonce
2. 帶 nonce 重簽                 → 200 ＋ 新的 DPoP-Nonce
3. 預先產生的 proof → 不要 nonce 的 RS → 200 （被騙過）
4. 同一張 → 要求 nonce 的 RS      → 401 use_dpop_nonce
5. 同一張 proof 原封重送          → 401 invalid_dpop_proof（jti 重複）
6. 同一個 nonce＋新 jti           → 200 （nonce 可重複使用）
== refresh token 綁定（AS）==
7. 第一次拿 token                → 200 DPoP ＋ refresh token
8. 偷來的 refresh token＋別的鑰匙 → 400 invalid_grant
9. 原主人＋同一把鑰匙            → 200 DPoP
```

- **第 3、4 行**：同一張預先產生的 proof，不要求 nonce 的 RS 被騙過；要求 nonce 的 RS 把它擋下。
- **第 5、6 行**：原封重送被 jti 擋下；同一個 nonce 搭配新的 jti，照常放行。
- **第 8 行**：回 `invalid_grant`。RFC 9449 沒有規定這種情況要回哪個錯誤碼；最貼近的是 RFC 6749 §5.2 的 `invalid_grant`（refresh token 無效、過期、已撤銷或發給了別的用戶端），業界實作（例如 Curity）也是這樣回。

> 程式裡的關鍵判斷是 `srv.recent.includes(r.P.nonce)`：伺服器只認自己最近發出去的值，攻擊者事先做好的 proof 自然對不上。

## 參考來源

- [RFC 9449 — DPoP](https://www.rfc-editor.org/rfc/rfc9449.html)：§4.2（ath 只在出示 access token 時帶）、§5（refresh token 綁定）、§8／§8.2（AS nonce）、§9（RS nonce）、§11.1（重放與 nonce 可重複使用）、§11.2（預先產生 proof）、§11.3（nonce 降級）、§11.4（不可匯出的私鑰）
- [RFC 9700 — OAuth 2.0 Security BCP](https://www.rfc-editor.org/rfc/rfc9700.html)：§2.2.2、§4.14.2（refresh token 綁定發送者／rotation）
- [RFC 6749 — OAuth 2.0](https://www.rfc-editor.org/rfc/rfc6749.html)：§1.5（refresh token）、§2.1（公開／機密用戶端）、§5.2（invalid\_grant）
- [Curity Identity Server 文件 — DPoP](https://curity.io/docs/identity-server/profiles/token-profile/clients/client-config/dpop)（refresh 時鑰匙不符回 invalid\_grant）
