oauth · L7

DPoP 進階:nonce 與 refresh token

Chance Lu · 學習文章 · 2026-10-30 18:01
Chance Lu

先回顧:第一篇講過,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

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

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)。

流程很單純:被拒絕時拿到一個值,簽進 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 的規則可以濃縮成三句:

綁鑰匙只擋得住「只偷到 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
程式裡的關鍵判斷是 srv.recent.includes(r.P.nonce):伺服器只認自己最近發出去的值,攻擊者事先做好的 proof 自然對不上。

參考來源