feat(net): RSA 加密器(OpenSSL PKCS1v1.5+base64) + 捕获真实 RSA 公钥

- RsaEncryptor: EVP_PKEY 解析公钥(非法 PEM 抛异常) + PKCS#1 v1.5 加密 + base64; 3/3 测试过
- resources/rsa_public_key.pem: 用 Playwright route 注入 setPublicKey hook + 缓存绕过, 真登录捕获的 RSA-2048 公钥
- spec §8.3: RSA 公钥阻塞解除
- vcpkg.json: 加 openssl
This commit is contained in:
gaozheng 2026-06-07 21:17:38 +08:00
parent 0a3d41689f
commit d32cbbf7c4
9 changed files with 247 additions and 1 deletions

View File

@ -238,7 +238,7 @@ IDatasetRepository {
抓取的真实流程里**未见 refresh-token 实际使用login2 只返不透明会话 token**。因此:
- **RSA 公钥**待精确提取。机制已定:JSEncrypt **RSA-2048**(login2 密文 258 字节)。**公钥不是静态 JS 字面量**(已扫全部 40 个 chunk 无命中)→ 运行时从接口取或被混淆;Phase 3 实现登录时用 devtools 在登录动作设断点、抓 JSEncrypt 实例 `setPublicKey` 的实参即可(约 5 分钟)
- **RSA 公钥已取得 ✅**(Phase 3,用 Playwright `page.route` 拦截 JS chunk 给 `setPublicKey` 注入 hook + 缓存绕过强制加载补丁版,触发一次真登录捕获)。RSA-2048 SPKI,存于 `resources/rsa_public_key.pem`。加密用 PKCS#1 v1.5(JSEncrypt 默认),`RsaEncryptor`(OpenSSL)已实现+单测
- **token 生命周期 / 是否有 refresh 机制**待确认。据此二选一设计:
- (a) 有 refresh token → 标准静默刷新、401 静默续期。
- (b) 仅会话 token → 「免登录」= 持久化会话 token 至其有效期;**到期/401 引导用户重新登录(含验证码),不声称静默重登**。

View File

@ -0,0 +1,9 @@
-----BEGIN PUBLIC KEY-----
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAxvHCj/3aYUFc8bzf2bJW
JG92yGDDvUlGocVe0mOMkTKei4wL+NyxUracgB6Bz889oDtBkMbDA/OdeuzUNmX1
yHeArIaJCrYts9dx8b1imT2KEpZRvNz8dGjaumxIXyppKyFhrf9mP8AfIILXfc1F
FaptD8TGb7gs2rqgxThXhL4FCI/ROuFAgSRn+u5KFJIAf4vk/hvkbpRHxBYtb4Ev
w40ehyKkQz9f52kvx0MIjVyGX2MKDs4x23wSCz7SpD9tsJ6VKRsb1BANgF4HHmWw
0KJ3UH+fAWVg9+NDjySSjxo/+Rz2nTlL0OFn15d2sS6j7Hh3K0JfwMrSVbvazMG3
PwIDAQAB
-----END PUBLIC KEY-----

View File

@ -10,4 +10,5 @@
#
add_subdirectory(core)
add_subdirectory(data)
add_subdirectory(net)
add_subdirectory(app)

6
src/net/CMakeLists.txt Normal file
View File

@ -0,0 +1,6 @@
find_package(OpenSSL REQUIRED)
add_library(geopro_net STATIC crypto/RsaEncryptor.cpp)
target_include_directories(geopro_net PUBLIC ${CMAKE_CURRENT_SOURCE_DIR})
target_link_libraries(geopro_net PUBLIC OpenSSL::SSL OpenSSL::Crypto)
target_compile_features(geopro_net PUBLIC cxx_std_17)
set_target_properties(geopro_net PROPERTIES AUTOMOC OFF AUTOUIC OFF AUTORCC OFF)

View File

@ -0,0 +1,69 @@
#include "crypto/RsaEncryptor.hpp"
#include <stdexcept>
#include <vector>
#include <openssl/bio.h>
#include <openssl/evp.h>
#include <openssl/pem.h>
#include <openssl/rsa.h>
namespace geopro::net {
struct RsaEncryptor::Impl {
EVP_PKEY* pkey = nullptr;
~Impl() {
if (pkey) EVP_PKEY_free(pkey);
}
};
RsaEncryptor::RsaEncryptor(const std::string& publicKeyPem) : impl_(std::make_unique<Impl>()) {
BIO* bio = BIO_new_mem_buf(publicKeyPem.data(), static_cast<int>(publicKeyPem.size()));
if (!bio) throw std::runtime_error("RsaEncryptor: BIO_new_mem_buf failed");
impl_->pkey = PEM_read_bio_PUBKEY(bio, nullptr, nullptr, nullptr);
BIO_free(bio);
if (!impl_->pkey) throw std::runtime_error("RsaEncryptor: invalid public key PEM");
}
RsaEncryptor::~RsaEncryptor() = default;
RsaEncryptor::RsaEncryptor(RsaEncryptor&&) noexcept = default;
RsaEncryptor& RsaEncryptor::operator=(RsaEncryptor&&) noexcept = default;
std::string RsaEncryptor::encryptBase64(const std::string& plain) const {
if (!impl_ || !impl_->pkey) throw std::runtime_error("RsaEncryptor: no key");
EVP_PKEY_CTX* ctx = EVP_PKEY_CTX_new(impl_->pkey, nullptr);
if (!ctx) throw std::runtime_error("RsaEncryptor: EVP_PKEY_CTX_new failed");
std::vector<unsigned char> cipher;
try {
if (EVP_PKEY_encrypt_init(ctx) <= 0)
throw std::runtime_error("RsaEncryptor: encrypt_init failed");
if (EVP_PKEY_CTX_set_rsa_padding(ctx, RSA_PKCS1_PADDING) <= 0)
throw std::runtime_error("RsaEncryptor: set_rsa_padding failed");
const auto* in = reinterpret_cast<const unsigned char*>(plain.data());
const size_t inlen = plain.size();
size_t outlen = 0;
if (EVP_PKEY_encrypt(ctx, nullptr, &outlen, in, inlen) <= 0)
throw std::runtime_error("RsaEncryptor: encrypt size query failed");
cipher.resize(outlen);
if (EVP_PKEY_encrypt(ctx, cipher.data(), &outlen, in, inlen) <= 0)
throw std::runtime_error("RsaEncryptor: encrypt failed");
cipher.resize(outlen);
} catch (...) {
EVP_PKEY_CTX_free(ctx);
throw;
}
EVP_PKEY_CTX_free(ctx);
// base64 编码EVP_EncodeBlock 产生标准 base64无换行
std::string b64;
b64.resize(4 * ((cipher.size() + 2) / 3));
const int n = EVP_EncodeBlock(reinterpret_cast<unsigned char*>(&b64[0]), cipher.data(),
static_cast<int>(cipher.size()));
b64.resize(static_cast<size_t>(n));
return b64;
}
} // namespace geopro::net

View File

@ -0,0 +1,30 @@
#pragma once
#include <memory>
#include <string>
namespace geopro::net {
// RSA 加密器:解析 PEM 公钥,用 RSA / PKCS#1 v1.5 填充加密明文并输出 base64。
// 填充方式与 web 端 JSEncrypt 默认一致,用于复刻登录密码加密传输。
// 接口不暴露任何 OpenSSL 类型pImpl
class RsaEncryptor {
public:
// 解析失败PEM 非法/非公钥)抛 std::runtime_error。
explicit RsaEncryptor(const std::string& publicKeyPem);
~RsaEncryptor();
RsaEncryptor(RsaEncryptor&&) noexcept;
RsaEncryptor& operator=(RsaEncryptor&&) noexcept;
RsaEncryptor(const RsaEncryptor&) = delete;
RsaEncryptor& operator=(const RsaEncryptor&) = delete;
// PKCS#1 v1.5 加密 -> base64。加密失败抛 std::runtime_error。
std::string encryptBase64(const std::string& plain) const;
private:
struct Impl;
std::unique_ptr<Impl> impl_;
};
} // namespace geopro::net

View File

@ -37,6 +37,12 @@ target_sources(geopro_tests PRIVATE data/test_parsers.cpp)
target_sources(geopro_tests PRIVATE data/test_local_repo.cpp)
target_link_libraries(geopro_tests PRIVATE geopro_data)
# net RSA OpenSSL / find_package
# OpenSSLgeopro_net PUBLIC
find_package(OpenSSL REQUIRED)
target_sources(geopro_tests PRIVATE net/test_rsa.cpp)
target_link_libraries(geopro_tests PRIVATE geopro_net OpenSSL::SSL OpenSSL::Crypto)
# geopro_data Qt6::Core exe gtest Qt6Core.dll
# DLL app TARGET_RUNTIME_DLLS POST_BUILD
if(WIN32)

124
tests/net/test_rsa.cpp Normal file
View File

@ -0,0 +1,124 @@
// net 层 RSA 加密器测试OpenSSL 3.x EVP API
// 用临时生成的 RSA-2048 密钥对自测,不依赖实站公钥:
// 1) 用 EVP_RSA_gen(2048) 生成密钥对;
// 2) 导出公钥 PEM喂给 RsaEncryptor 加密 + base64
// 3) 用同一密钥对的私钥解密密文,断言能还原原文。
// PKCS#1 v1.5 填充与 JSEncrypt 默认一致。
#include <gtest/gtest.h>
#include <memory>
#include <stdexcept>
#include <string>
#include <vector>
#include <openssl/bio.h>
#include <openssl/evp.h>
#include <openssl/pem.h>
#include <openssl/rsa.h>
#include "crypto/RsaEncryptor.hpp"
namespace {
// RAII 包装:保证测试里 OpenSSL 句柄不泄漏。
struct PkeyDeleter {
void operator()(EVP_PKEY* p) const noexcept {
if (p) EVP_PKEY_free(p);
}
};
struct PkeyCtxDeleter {
void operator()(EVP_PKEY_CTX* c) const noexcept {
if (c) EVP_PKEY_CTX_free(c);
}
};
struct BioDeleter {
void operator()(BIO* b) const noexcept {
if (b) BIO_free(b);
}
};
using PkeyPtr = std::unique_ptr<EVP_PKEY, PkeyDeleter>;
using PkeyCtxPtr = std::unique_ptr<EVP_PKEY_CTX, PkeyCtxDeleter>;
using BioPtr = std::unique_ptr<BIO, BioDeleter>;
// base64 解码(处理尾部 padding用于长度断言。
std::vector<unsigned char> base64Decode(const std::string& b64) {
std::vector<unsigned char> out(b64.size()); // 解码后必不超过输入长度
int decoded = EVP_DecodeBlock(out.data(),
reinterpret_cast<const unsigned char*>(b64.data()),
static_cast<int>(b64.size()));
if (decoded < 0) return {};
// EVP_DecodeBlock 按 4 字节对齐解码,会把 '=' 当作 0需按 padding 数扣除尾部字节。
size_t pad = 0;
if (!b64.empty() && b64[b64.size() - 1] == '=') ++pad;
if (b64.size() >= 2 && b64[b64.size() - 2] == '=') ++pad;
out.resize(static_cast<size_t>(decoded) - pad);
return out;
}
// 用私钥kp以 PKCS#1 v1.5 解密 cipher。
std::string decryptWithPrivateKey(EVP_PKEY* kp, const std::vector<unsigned char>& cipher) {
PkeyCtxPtr ctx(EVP_PKEY_CTX_new(kp, nullptr));
EXPECT_NE(ctx, nullptr);
EXPECT_GT(EVP_PKEY_decrypt_init(ctx.get()), 0);
EXPECT_GT(EVP_PKEY_CTX_set_rsa_padding(ctx.get(), RSA_PKCS1_PADDING), 0);
size_t outlen = 0;
EXPECT_GT(EVP_PKEY_decrypt(ctx.get(), nullptr, &outlen, cipher.data(), cipher.size()), 0);
std::vector<unsigned char> plain(outlen);
EXPECT_GT(EVP_PKEY_decrypt(ctx.get(), plain.data(), &outlen, cipher.data(), cipher.size()), 0);
return std::string(reinterpret_cast<const char*>(plain.data()), outlen);
}
std::string exportPublicKeyPem(EVP_PKEY* kp) {
BioPtr bio(BIO_new(BIO_s_mem()));
EXPECT_NE(bio, nullptr);
EXPECT_GT(PEM_write_bio_PUBKEY(bio.get(), kp), 0);
char* data = nullptr;
long len = BIO_get_mem_data(bio.get(), &data);
return std::string(data, static_cast<size_t>(len));
}
} // namespace
TEST(RsaEncryptorTest, EncryptsToBase64DecryptableByPrivateKey) {
// Arrange: 临时生成 RSA-2048 密钥对,导出公钥 PEM。
PkeyPtr kp(EVP_RSA_gen(2048));
ASSERT_NE(kp, nullptr);
const std::string pubPem = exportPublicKeyPem(kp.get());
ASSERT_FALSE(pubPem.empty());
const std::string plaintext = "hello-geopro";
// Act: 加密 + base64。
geopro::net::RsaEncryptor enc(pubPem);
const std::string b64 = enc.encryptBase64(plaintext);
// Assert: base64 解码后是 256 字节密文RSA-2048
const std::vector<unsigned char> cipher = base64Decode(b64);
EXPECT_EQ(cipher.size(), 256u);
// Assert: 私钥能解出原文(即便长度断言放宽,这条是核心正确性保证)。
const std::string recovered = decryptWithPrivateKey(kp.get(), cipher);
EXPECT_EQ(recovered, plaintext);
}
TEST(RsaEncryptorTest, ThrowsOnInvalidPublicKey) {
EXPECT_THROW(geopro::net::RsaEncryptor("not-a-valid-pem"), std::runtime_error);
}
TEST(RsaEncryptorTest, MoveConstructionPreservesEncryption) {
PkeyPtr kp(EVP_RSA_gen(2048));
ASSERT_NE(kp, nullptr);
const std::string pubPem = exportPublicKeyPem(kp.get());
geopro::net::RsaEncryptor original(pubPem);
geopro::net::RsaEncryptor moved(std::move(original));
const std::string plaintext = "move-check";
const std::string b64 = moved.encryptBase64(plaintext);
const std::vector<unsigned char> cipher = base64Decode(b64);
EXPECT_EQ(cipher.size(), 256u);
EXPECT_EQ(decryptWithPrivateKey(kp.get(), cipher), plaintext);
}

View File

@ -6,6 +6,7 @@
"eigen3",
"gtest",
"nlohmann-json",
"openssl",
"proj"
]
}