PAYATHON 2026

C++ 使用 OpenSSL 验证 JWT RS256 签名为何失败?

排障赵师傅

仅用模数 n 和指数 e 就能构造 RSA 公钥,无需 PEM 文件或证书。原代码需要先修正公钥参数的字节序:Base64url 解码后的 n 是大端整数,而 OSSL_PARAM_construct_BN() 按本机字节序解释缓冲区。在小端机器上,直接传入这些字节会得到错误的模数。

t_sig.size() == 256、t_hash_len == 32 都是正常的:2048 位 RSA 的签名占 256 字节,SHA-256 摘要占 32 字节,两者不需要等长。

为什么公钥导入成功,验签仍然失败?

JWK 中的 RSA 参数使用 Base64urlUInt 编码,解码后是无符号大端整数,最高有效字节在前。RFC 7518 §2

以下调用只描述整数缓冲区,不会把大端数据转换为本机字节序:

OSSL_PARAM_construct_BN(
    "n",
    reinterpret_cast<unsigned char*>(/* buffer */),
    /* size */
);

OSSL_PARAM_UNSIGNED_INTEGER 要求缓冲区采用本机字节序,所以在小端机器上,原代码会反向解释模数的字节顺序。OpenSSL OSSL_PARAM 文档

指数 AQAB 解码后是:

01 00 01

它表示 65537,字节顺序反转后恰好不变,因此很难从指数上发现这个错误。

EVP_PKEY_fromdata() 返回成功,只能说明导入操作成功,不能证明导入的数值与预期公钥一致。即使字节序错误,缓冲区仍可能表示一个可被接受的整数。

正确导入模数和指数

  1. 对 n、e 做 Base64url 解码。
  2. 使用 BN_bin2bn(),按大端格式读取解码后的字节。
  3. 使用 OSSL_PARAM_BLD_push_BN() 构造参数。
  4. 调用 EVP_PKEY_fromdata() 导入公钥。

BN_bin2bn() 接受大端二进制整数,参数构造器负责生成 OpenSSL 所需的整数表示,这样就不用手动判断主机字节序。BN 转换文档、参数构造器文档

下面的 C++11 示例接收已解码的二进制字符串,可以替换原代码的公钥构造部分:

#include <openssl/bn.h>
#include <openssl/core_names.h>
#include <openssl/evp.h>
#include <openssl/param_build.h>
#include <openssl/params.h>

#include <climits>
#include <memory>
#include <stdexcept>
#include <string>

using BNPtr = std::unique_ptr<BIGNUM, decltype(&BN_free)>;
using PKeyPtr = std::unique_ptr<EVP_PKEY, decltype(&EVP_PKEY_free)>;
using PKeyCtxPtr =
    std::unique_ptr<EVP_PKEY_CTX, decltype(&EVP_PKEY_CTX_free)>;
using BuilderPtr =
    std::unique_ptr<OSSL_PARAM_BLD, decltype(&OSSL_PARAM_BLD_free)>;
using ParamsPtr =
    std::unique_ptr<OSSL_PARAM, decltype(&OSSL_PARAM_free)>;

static void require(bool ok, const char* message)
{
    if (!ok)
        throw std::runtime_error(message);
}

PKeyPtr load_rsa_public_key(
    const std::string& rsa_n,
    const std::string& rsa_e)
{
    require(!rsa_n.empty() && !rsa_e.empty(), "Empty RSA parameter");
    require(rsa_n.size() <= static_cast<std::size_t>(INT_MAX) &&
            rsa_e.size() <= static_cast<std::size_t>(INT_MAX),
            "RSA parameter too large");

    BNPtr n(BN_bin2bn(
                reinterpret_cast<const unsigned char*>(rsa_n.data()),
                static_cast<int>(rsa_n.size()), nullptr),
            BN_free);
    BNPtr e(BN_bin2bn(
                reinterpret_cast<const unsigned char*>(rsa_e.data()),
                static_cast<int>(rsa_e.size()), nullptr),
            BN_free);
    require(n && e, "BN_bin2bn failed");

    BuilderPtr builder(OSSL_PARAM_BLD_new(), OSSL_PARAM_BLD_free);
    require(static_cast<bool>(builder), "OSSL_PARAM_BLD_new failed");

    require(OSSL_PARAM_BLD_push_BN(
                builder.get(), OSSL_PKEY_PARAM_RSA_N, n.get()) == 1 &&
            OSSL_PARAM_BLD_push_BN(
                builder.get(), OSSL_PKEY_PARAM_RSA_E, e.get()) == 1,
            "Building RSA parameters failed");

    ParamsPtr params(
        OSSL_PARAM_BLD_to_param(builder.get()), OSSL_PARAM_free);
    require(static_cast<bool>(params), "OSSL_PARAM_BLD_to_param failed");

    PKeyCtxPtr ctx(
        EVP_PKEY_CTX_new_from_name(nullptr, "RSA", nullptr),
        EVP_PKEY_CTX_free);
    require(static_cast<bool>(ctx), "Creating RSA context failed");
    require(EVP_PKEY_fromdata_init(ctx.get()) == 1,
            "EVP_PKEY_fromdata_init failed");

    EVP_PKEY* raw = nullptr;
    const int result = EVP_PKEY_fromdata(
        ctx.get(), &raw, EVP_PKEY_PUBLIC_KEY, params.get());

    PKeyPtr key(raw, EVP_PKEY_free);
    require(result == 1 && key, "Importing RSA public key failed");
    return key;
}

在 OSSL_PARAM_BLD_to_param() 完成前,构造器一直引用原来的 BIGNUM,因此 n、e 必须保持有效。

调用时,要先确认解码器支持 JWT 使用的无填充 Base64url:

const std::string rsa_n = base64_decode(n_base64_url);
const std::string rsa_e = base64_decode(e_base64_url);

auto pkey = load_rsa_public_key(rsa_n, rsa_e);

如何确认导入的公钥参数正确?

使用 EVP_PKEY_get_bn_param() 读回参数,再与预期整数比较。接口返回的 BIGNUM 需要由调用方释放。OpenSSL 公钥参数读取文档

下面的函数用于比较模数和指数,类型别名沿用前面的代码:

bool matches_hex(
    const EVP_PKEY* key,
    const char* parameter,
    const std::string& expected_hex)
{
    BIGNUM* loaded_raw = nullptr;
    const int got = EVP_PKEY_get_bn_param(
        key, parameter, &loaded_raw);
    BNPtr loaded(loaded_raw, BN_free);

    BIGNUM* expected_raw = nullptr;
    const int parsed = BN_hex2bn(
        &expected_raw, expected_hex.c_str());
    BNPtr expected(expected_raw, BN_free);

    return got == 1 &&
           parsed > 0 &&
           static_cast<std::size_t>(parsed) == expected_hex.size() &&
           loaded && expected &&
           BN_cmp(loaded.get(), expected.get()) == 0;
}

用题目提供的数据检查:

require(matches_hex(
            pkey.get(), OSSL_PKEY_PARAM_RSA_N, n_hex),
        "Loaded modulus does not match");

require(matches_hex(
            pkey.get(), OSSL_PKEY_PARAM_RSA_E, e_hex),
        "Loaded exponent does not match");

比较通过,说明公钥对象中的数值与给定的十六进制值一致。这把公钥是否与 token 的签名匹配,还需要通过验签确认。

也可以调用 EVP_PKEY_public_check() 检查公钥有效性,但它不能代替参数比较或签名验证。

原来的 SHA-256 和验签流程是否正确?

原代码使用:

EVP_SIGNATURE_fetch(nullptr, "RSA-SHA256", nullptr);
EVP_PKEY_verify_init_ex2(...);
EVP_PKEY_verify(..., t_hash, t_hash_len);

按照 OpenSSL 3.5 文档,这种初始化模式接受预先计算的 SHA-256 摘要。"RSA-SHA256" 已确定摘要算法和 PKCS#1 v1.5 填充,仅凭算法名称不能认定这里发生了重复哈希。OpenSSL EVP_PKEY_verify 文档

也可以使用普通 RSA 验签上下文,显式指定填充和摘要算法,让设置更直观:

#include <openssl/rsa.h>

// t_hash 是 SHA256(t_dat) 的二进制结果。
PKeyCtxPtr vctx(
    EVP_PKEY_CTX_new_from_pkey(nullptr, pkey.get(), nullptr),
    EVP_PKEY_CTX_free);

require(static_cast<bool>(vctx), "Creating verification context failed");
require(EVP_PKEY_verify_init(vctx.get()) == 1,
        "EVP_PKEY_verify_init failed");
require(EVP_PKEY_CTX_set_rsa_padding(
            vctx.get(), RSA_PKCS1_PADDING) == 1,
        "Setting RSA padding failed");
require(EVP_PKEY_CTX_set_signature_md(
            vctx.get(), EVP_sha256()) == 1,
        "Setting signature digest failed");

const int v = EVP_PKEY_verify(
    vctx.get(),
    reinterpret_cast<const unsigned char*>(t_sig.data()),
    t_sig.size(),
    t_hash,
    t_hash_len);

RS256 使用 RSASSA-PKCS1-v1_5 和 SHA-256;RSA-PSS 对应的是 PS256,两者不能混用。RFC 7518 §3.3

如果改用 EVP_DigestVerifyInit()、EVP_DigestVerifyUpdate() 和 EVP_DigestVerifyFinal(),应向 Update() 传入原始 header.payload,由接口内部完成哈希。

为什么签名长度和摘要长度不同?

参数内容当前示例中的长度
t_sigBase64url 解码后的 RSA 签名256 字节
t_hashSHA256(header.payload) 的二进制摘要32 字节

RSA 签名长度由模数长度决定,为 ceil(bit_length(n) / 8);SHA-256 摘要长度固定为 32 字节。验签时会检查 RSA 签名编码中的摘要信息,因此无需把摘要补齐到 256 字节,也不能截断签名。

摘要缓冲区可以改成:

unsigned char t_hash[EVP_MAX_MD_SIZE];
unsigned int t_hash_len = 0;

EVP_DigestFinal_ex() 成功后,使用它写入的 t_hash_len 作为摘要长度。原来的 2048 字节缓冲区足够大,但不需要这么多空间。

修正后仍然失败,应检查什么?

先确认待签名数据保持原样。对于题目中的普通 JWT Compact 格式,验签输入是 token 前两段的原始编码文本,中间用一个点号连接:

base64url(header) + "." + base64url(payload)

原来的 t_dat = token.substr(0, s2) 思路正确。不能拼接解码后的 JSON,也不能解析后重新序列化再计算摘要。RFC 7515 §3.1

还需要检查:

  • token 恰好包含三个非空段,且拆分时检查了 std::string::npos。
  • n、e、signature 都按 Base64url 解码,并支持省略 = 填充。
  • t_sig 和 t_hash 按二进制数据处理,长度使用 .size() 或接口返回值,不使用 strlen()。
  • token 与公钥来自同一组测试数据。header、payload 或 signature 中任意一个字符发生变化,都可能导致验签失败。

解码函数的第二个布尔参数不一定表示“启用 URL 模式”。Rene Nyffenegger 当前仓库实现中的 base64_decode(s, true) 表示移除换行;解码逻辑本身同时接受 -、_ 和 +、/。具体行为要以项目使用的源码版本为准。解码器源码

处理验签结果时,要区分返回值:

  • v == 1:签名验证成功。
  • v == 0:签名验证未通过。
  • v < 0:发生其他错误,例如操作不受支持。

失败后可以立即调用 ERR_print_errors_fp(stderr) 辅助诊断,最终结果仍以返回值为准。原代码通过 EVP_SIGNATURE_fetch() 获得的对象还需要调用 EVP_SIGNATURE_free() 释放。

实际接入时,验签成功后还要按应用规则检查 exp、nbf、iss、aud 等声明,并限制允许的算法和可信密钥来源。仅得到 PASS,还不足以决定是否接受整个 JWT。JWT 最佳实践

备注:内容仅供参考。