PKCE 防的是什么
授权码流程中,同设备上的恶意应用可以注册自定义 URI scheme,截获携带 code 的重定向。PKCE 增加一个截获者没有的秘密:客户端在授权时发送随机 verifier 的哈希,在换取令牌时发送原始 verifier,单独拿到 code 毫无用处。
常见错误
第一类错误是在授权请求带了 challenge 的情况下,接受没有 code_verifier 的令牌请求,PKCE 变成装饰。第二类是允许 code_challenge_method=plain,此时 challenge 等于 verifier,看到授权请求的攻击者就掌握了秘密;应强制 S256。
第三类是在换取令牌时新生成 verifier,永远无法与已保存的 challenge 匹配。其他问题包括省略 state,留下登录 CSRF 和 mix-up 攻击空间;注册通配或共享 redirect URI;允许 code 复用;以及把 verifier 和 code 一起写进日志。
何时使用与取舍
所有授权码客户端都应使用 PKCE,包括机密 Web 客户端,因为它能防御通过开放重定向注入 code。公共客户端无法保存 client secret,风险最高。隐式流程和密码流程应淘汰,而不是打补丁。
PKCE 增加客户端状态管理,需要跨标签页和应用重启正确保存 verifier。原生应用仍需 claimed HTTPS 或已验证的应用链接,因为任何应用都能注册自定义 scheme。服务端必须拒绝不匹配,而不是降级发令牌。
示例与常见误解
攻击者的应用注册 com.example.app://callback 并截获 code,但没有 verifier,换取时收到 invalid_grant。如果服务端接受缺失 verifier,这个 code 就能换到令牌。
常见误解是 PKCE 用于认证客户端。它把 code 绑定到发起流程的客户端实例,客户端认证是另一项独立控制。相关概念:OAuth 2.0 授权码流程与 PKCE、OAuth 作用域、OpenID Connect。
代码示例
type PathTree = Map<string, PathTree>;
const FORBIDDEN_KEYS = new Set(["__proto__", "constructor", "prototype"]);
export function omitDeep<Value>(value: Value, paths: readonly string[]): Value {
if (!Array.isArray(paths)) throw new TypeError("paths must be an array");
if (paths.length === 0) return value;
const tree: PathTree = new Map();
for (const path of paths) {
if (typeof path !== "string" || path.trim() === "") {
throw new TypeError("paths must be non-empty strings");
}
const segments = path.split(".").map((segment) => segment.trim());
if (segments.some((segment) => segment === "")) throw new Error(`invalid path "${path}"`);
for (const segment of segments) {
if (FORBIDDEN_KEYS.has(segment)) throw new Error(`path "${path}" contains a forbidden key`);
}
insert(tree, segments);
}
return clone(value, tree) as Value;
}
function insert(tree: PathTree, segments: readonly string[]): void {
let node = tree;
for (const segment of segments) {
let child = node.get(segment);
if (child === undefined) {
child = new Map();
node.set(segment, child);
}
node = child;
}
}
function clone(value: unknown, tree: PathTree): unknown {
if (Array.isArray(value)) {
return value.map((item, index) => {
const child = tree.get(String(index));
if (child !== undefined && child.size === 0) return undefined;
return clone(item, child ?? tree);
});
}
if (value !== null && typeof value === "object") {
const prototype = Object.getPrototypeOf(value);
if (prototype !== Object.prototype && prototype !== null) {
throw new TypeError("omitDeep only supports plain objects and arrays");
}
const output: Record<string, unknown> = {};
for (const [key, item] of Object.entries(value as Record<string, unknown>)) {
const child = tree.get(key);
if (child !== undefined && child.size === 0) continue;
output[key] = clone(item, child ?? new Map());
}
return output;
}
return value;
}
const source = {
id: 1,
password: "secret",
profile: { email: "[email protected]", token: "abc", nested: { keep: true } },
items: [{ id: 1, token: "a" }, { id: 2, token: "b" }],
};
const sanitized = omitDeep(source, ["password", "profile.token", "items.token"]);
console.log(JSON.stringify(sanitized));
console.log(JSON.stringify(source));
function assertSecret(secret: string): void {
if (typeof secret !== "string" || secret.length === 0) {
throw new TypeError("secret must be a non-empty string");
}
}
export async function deriveAesKey(
secret: string,
salt: Uint8Array,
info: string,
): Promise<CryptoKey> {
assertSecret(secret);
if (!(salt instanceof Uint8Array)) throw new TypeError("salt must be a Uint8Array");
if (typeof info !== "string") throw new TypeError("info must be a string");
const material = await crypto.subtle.importKey(
"raw",
new TextEncoder().encode(secret),
"HKDF",
false,
["deriveKey"],
);
return crypto.subtle.deriveKey(
{ name: "HKDF", hash: "SHA-256", salt, info: new TextEncoder().encode(info) },
material,
{ name: "AES-GCM", length: 256 },
false,
["encrypt", "decrypt"],
);
}
export async function deriveBytes(
secret: string,
salt: Uint8Array,
info: string,
length = 32,
): Promise<Uint8Array> {
assertSecret(secret);
if (!(salt instanceof Uint8Array)) throw new TypeError("salt must be a Uint8Array");
if (typeof info !== "string") throw new TypeError("info must be a string");
if (!Number.isInteger(length) || length < 1 || length > 255) {
throw new RangeError("length must be an integer between 1 and 255");
}
const material = await crypto.subtle.importKey(
"raw",
new TextEncoder().encode(secret),
"HKDF",
false,
["deriveBits"],
);
const bits = await crypto.subtle.deriveBits(
{ name: "HKDF", hash: "SHA-256", salt, info: new TextEncoder().encode(info) },
material,
length * 8,
);
return new Uint8Array(bits);
}