先回顧:第一篇講過,DPoP 讓用戶端每個請求都附一張用私鑰當場簽的 proof。token 透過 cnf.jkt 記住公鑰指紋,proof 則用 ath、htm/htu 綁住這顆 token 和這一次請求。
這篇補兩個洞:一是攻擊者「事先」簽好的 proof 怎麼擋,二是 refresh token 被偷了怎麼辦。
點圖可放大。① 第一篇已經處理過;② 和 ③ 是這篇的主角。
三種攻擊各有一道主要的擋法。不過 ② 的 nonce 也會回頭保護 ③:攻擊者如果連打 /token 用的 proof 都事先簽好了,就要靠 AS 的 nonce 擋下來(見下半)。
iat 填成未來的時間,然後帶走。之後他就算再也碰不到私鑰,到了那些時間點,這些 proof 還是能拿出來用(§11.2)。nonce 證明 proof「簽得夠晚」;refresh token 是能一直換出新 access token 的「長期票」。
ath,所以預先簽好的 proof 最多只能用到那顆 access token 過期為止(§11.2)。但打 /token 的 proof 不出示 access token,所以沒有 ath(§4.2),也就沒有這個期限。這一點下半會再回來談。預先產生的 proof:jti 和 iat 都擋不住,nonce 擋得住。每張 proof 的 jti 都不同,伺服器從沒看過;iat 又剛好落在使用的時間點附近,時間窗也會放行。nonce 是伺服器事後才發的,而且猜不到,攻擊者當初簽的時候不可能知道(§11.2)。
原封重送:主要靠時間窗加 jti 擋。nonce 能把可重送的時間再縮短一些(§11.1)。但同一個 nonce 可以重複使用,所以單靠 nonce 擋不掉同一張 proof 被重送。
nonce 獨有的作用,是擋「預先產生的 proof」。
nonce 不是每台伺服器都必須啟用,要不要用由伺服器自己決定(§8 用的是 MAY)。但只要它發過 nonce 給某個用戶端,之後就不能再接受那個用戶端不帶 nonce 的 proof(§11.3),否則攻擊者只要把 nonce 拿掉就能繞過。
點圖可放大。高亮會依序走過 ①→⑤。AS 的流程相同,只是 ② 改回 HTTP 400,error 放在 JSON body 裡(§8);RS 回 401,錯誤寫在 WWW-Authenticate 標頭(§9)。
nonce claim 就好,不必理解內容(§8)。DPoP-Nonce。用戶端在拿到新值之前,繼續用手上那個(§8.2)。流程很單純:被拒絕時拿到一個值,簽進 proof 重送;之後伺服器換新值,用戶端就跟著換。
點圖可放大。攻擊者偷到 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。
延續第一篇的寫法,只用 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
invalid_grant。RFC 9449 沒有規定這種情況要回哪個錯誤碼;最貼近的是 RFC 6749 §5.2 的 invalid_grant(refresh token 無效、過期、已撤銷或發給了別的用戶端),業界實作(例如 Curity)也是這樣回。程式裡的關鍵判斷是 srv.recent.includes(r.P.nonce):伺服器只認自己最近發出去的值,攻擊者事先做好的 proof 自然對不上。