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() 返回成功,只能说明导入操作成功,不能证明导入的数值与预期公钥一致。即使字节序错误,缓冲区仍可能表示一个可被接受的整数。
正确导入模数和指数
- 对
n、e做 Base64url 解码。 - 使用
BN_bin2bn(),按大端格式读取解码后的字节。 - 使用
OSSL_PARAM_BLD_push_BN()构造参数。 - 调用
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_sig | Base64url 解码后的 RSA 签名 | 256 字节 |
t_hash | SHA256(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 最佳实践
备注:内容仅供参考。
