← 中文资源

简体中文版本

JWT 算法混淆

JWT 算法混淆利用“令牌头部自报算法”这一攻击者可控输入:none 剥掉签名,或用 RSA 公钥当 HMAC 密钥——根因是校验方信任头部而非钉死算法。

阅读英文原文

概念定义

令牌声明自己希望如何被校验,信任这个声明就等于让攻击者挑最弱路径:完全不要签名,或用“本不保密”的公钥做 HMAC 密钥。历史上两者都产生过被接受的伪造令牌。

工作机制

none 变体里第三段为空,宽容的库直接跳过校验。密钥混淆变体里,校验方把 RSA 公钥字节当作 HMAC 共享密钥——攻击者同样知道这些公开字节,于是能合法签名。防御是:期望算法是每个签发者与密钥对应的服务器常量,头部必须精确匹配,且密钥类型与算法互相校验。

使用场景

审计重点是自定义校验代码、密钥轮换服务、以及接受多签发者令牌的地方。库必须钉死算法;JWKS 的密钥类型要与令牌算法核对。集成测试应提交 alg 为 none 与跨族令牌并断言被拒。

常见误区

“现代库已永久修复”过于乐观——缺陷会在手写校验与不区分密钥类型的分支里复活。密钥混淆不需要私钥,公开字节就够。有的库暴露 algorithms 参数但默认宽松;多签发者系统常按 kid 选键却忘了按 alg 选校验器——两者必须对齐。

代码示例

export type DirectiveValue = readonly string[];

export interface CspOptions {
  readonly directives?: Readonly<Record<string, DirectiveValue>>;
  readonly reportOnly?: boolean;
  readonly nonce?: string;
}

const BASE_DIRECTIVES: Record<string, readonly string[]> = {
  "default-src": ["'self'"],
  "script-src": ["'self'"],
  "style-src": ["'self'"],
  "img-src": ["'self'", "data:"],
  "object-src": ["'none'"],
  "base-uri": ["'self'"],
  "frame-ancestors": ["'none'"],
};

export function createNonce(): string {
  const bytes = new Uint8Array(16);
  globalThis.crypto.getRandomValues(bytes);
  let binary = "";
  for (const byte of bytes) binary += String.fromCharCode(byte);
  return btoa(binary);
}

export function buildCsp(options: CspOptions = {}): { name: string; value: string } {
  const directives: Record<string, readonly string[]> = { ...BASE_DIRECTIVES };
  if (options.nonce !== undefined) {
    if (!/^[A-Za-z0-9+/]+={0,2}$/.test(options.nonce)) throw new Error("nonce must be base64");
    directives["script-src"] = [...(directives["script-src"] ?? []), `'nonce-${options.nonce}'`];
  }
  for (const [name, values] of Object.entries(options.directives ?? {})) {
    if (!/^[a-z][a-z0-9-]*$/.test(name)) throw new Error(`invalid directive name "${name}"`);
    for (const value of values) {
      if (typeof value !== "string" || /[;\s]/.test(value)) {
        throw new Error(`invalid value for "${name}"`);
      }
    }
    directives[name] = values;
  }
  const serialized = Object.entries(directives)
    .filter(([, values]) => values.length > 0)
    .map(([name, values]) => `${name} ${values.join(" ")}`)
    .join("; ");
  return {
    name: options.reportOnly === true ? "Content-Security-Policy-Report-Only" : "Content-Security-Policy",
    value: serialized,
  };
}

const header = buildCsp({
  nonce: createNonce(),
  directives: {
    "connect-src": ["'self'", "https://api.example.com"],
    "img-src": ["'self'", "data:", "https://cdn.example.com"],
  },
});
console.log(header.name);
console.log(header.value);
console.log(buildCsp({ reportOnly: true }).name);
import { createHmac, timingSafeEqual } from "node:crypto";

export interface SignatureOptions {
  readonly secret: string;
  readonly toleranceSeconds?: number;
  readonly now?: number;
}

export function signWebhook(payload: string, timestamp: number, secret: string): string {
  if (typeof secret !== "string" || secret.length < 16) {
    throw new RangeError("secret must contain at least 16 characters");
  }
  if (!Number.isInteger(timestamp)) throw new TypeError("timestamp must be an integer");
  const signature = createHmac("sha256", secret).update(`${timestamp}.${payload}`).digest("hex");
  return `t=${timestamp},v1=${signature}`;
}

export function verifyWebhook(payload: string, header: string, options: SignatureOptions): boolean {
  if (typeof header !== "string") throw new TypeError("header must be a string");
  const { secret, toleranceSeconds = 300, now = Date.now() } = options;
  if (typeof secret !== "string" || secret.length < 16) {
    throw new RangeError("secret must contain at least 16 characters");
  }
  const parts = new Map<string, string>();
  for (const part of header.split(",")) {
    const equals = part.indexOf("=");
    if (equals === -1) continue;
    parts.set(part.slice(0, equals).trim(), part.slice(equals + 1).trim());
  }
  const timestampText = parts.get("t");
  const provided = parts.get("v1");
  if (timestampText === undefined || provided === undefined) return false;
  const timestamp = Number(timestampText);
  if (!Number.isInteger(timestamp)) return false;
  if (Math.abs(now / 1000 - timestamp) > toleranceSeconds) return false;
  const expected = createHmac("sha256", secret).update(`${timestamp}.${payload}`).digest("hex");
  const left = fromHex(expected);
  const right = fromHex(provided);
  if (left.length === 0 || left.length !== right.length) return false;
  return timingSafeEqual(left, right);
}

function fromHex(value: string): Uint8Array {
  if (value.length % 2 !== 0 || !/^[0-9a-f]*$/i.test(value)) return new Uint8Array(0);
  const bytes = new Uint8Array(value.length / 2);
  for (let index = 0; index < bytes.length; index += 1) {
    bytes[index] = Number.parseInt(value.slice(index * 2, index * 2 + 2), 16);
  }
  return bytes;
}

const secret = "0123456789abcdef0123456789abcdef";
const payload = JSON.stringify({ event: "invoice.paid", id: "evt_42" });
const header = signWebhook(payload, 1760000000, secret);
console.log(header);
console.log(verifyWebhook(payload, header, { secret, now: 1760000100 * 1000 }));
console.log(verifyWebhook(`${payload} `, header, { secret, now: 1760000100 * 1000 }));
console.log(verifyWebhook(payload, header, { secret, toleranceSeconds: 10, now: 1760000100 * 1000 }));