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:
parent
0a3d41689f
commit
d32cbbf7c4
|
|
@ -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 引导用户重新登录(含验证码),不声称静默重登**。
|
||||
|
|
|
|||
|
|
@ -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-----
|
||||
|
|
@ -10,4 +10,5 @@
|
|||
#
|
||||
add_subdirectory(core)
|
||||
add_subdirectory(data)
|
||||
add_subdirectory(net)
|
||||
add_subdirectory(app)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
# 并链接 OpenSSL(geopro_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)
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -6,6 +6,7 @@
|
|||
"eigen3",
|
||||
"gtest",
|
||||
"nlohmann-json",
|
||||
"openssl",
|
||||
"proj"
|
||||
]
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue