diff --git a/docs/superpowers/specs/2026-06-07-geopro-desktop-m1-design.md b/docs/superpowers/specs/2026-06-07-geopro-desktop-m1-design.md index 36ee63c..b376c0a 100644 --- a/docs/superpowers/specs/2026-06-07-geopro-desktop-m1-design.md +++ b/docs/superpowers/specs/2026-06-07-geopro-desktop-m1-design.md @@ -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 引导用户重新登录(含验证码),不声称静默重登**。 diff --git a/resources/rsa_public_key.pem b/resources/rsa_public_key.pem new file mode 100644 index 0000000..1c8f29b --- /dev/null +++ b/resources/rsa_public_key.pem @@ -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----- diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index a1a7f2f..a7d8f05 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -10,4 +10,5 @@ # add_subdirectory(core) add_subdirectory(data) +add_subdirectory(net) add_subdirectory(app) diff --git a/src/net/CMakeLists.txt b/src/net/CMakeLists.txt new file mode 100644 index 0000000..f72be36 --- /dev/null +++ b/src/net/CMakeLists.txt @@ -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) diff --git a/src/net/crypto/RsaEncryptor.cpp b/src/net/crypto/RsaEncryptor.cpp new file mode 100644 index 0000000..f4f46de --- /dev/null +++ b/src/net/crypto/RsaEncryptor.cpp @@ -0,0 +1,69 @@ +#include "crypto/RsaEncryptor.hpp" + +#include +#include + +#include +#include +#include +#include + +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()) { + BIO* bio = BIO_new_mem_buf(publicKeyPem.data(), static_cast(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 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(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(&b64[0]), cipher.data(), + static_cast(cipher.size())); + b64.resize(static_cast(n)); + return b64; +} + +} // namespace geopro::net diff --git a/src/net/crypto/RsaEncryptor.hpp b/src/net/crypto/RsaEncryptor.hpp new file mode 100644 index 0000000..5e5b24e --- /dev/null +++ b/src/net/crypto/RsaEncryptor.hpp @@ -0,0 +1,30 @@ +#pragma once +#include +#include + +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_; +}; + +} // namespace geopro::net diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 30c58b7..4158cb2 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -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) diff --git a/tests/net/test_rsa.cpp b/tests/net/test_rsa.cpp new file mode 100644 index 0000000..8b1b406 --- /dev/null +++ b/tests/net/test_rsa.cpp @@ -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 + +#include +#include +#include +#include + +#include +#include +#include +#include + +#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; +using PkeyCtxPtr = std::unique_ptr; +using BioPtr = std::unique_ptr; + +// base64 解码(处理尾部 padding),用于长度断言。 +std::vector base64Decode(const std::string& b64) { + std::vector out(b64.size()); // 解码后必不超过输入长度 + int decoded = EVP_DecodeBlock(out.data(), + reinterpret_cast(b64.data()), + static_cast(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(decoded) - pad); + return out; +} + +// 用私钥(kp)以 PKCS#1 v1.5 解密 cipher。 +std::string decryptWithPrivateKey(EVP_PKEY* kp, const std::vector& 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 plain(outlen); + EXPECT_GT(EVP_PKEY_decrypt(ctx.get(), plain.data(), &outlen, cipher.data(), cipher.size()), 0); + return std::string(reinterpret_cast(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(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 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 cipher = base64Decode(b64); + EXPECT_EQ(cipher.size(), 256u); + EXPECT_EQ(decryptWithPrivateKey(kp.get(), cipher), plaintext); +} diff --git a/vcpkg.json b/vcpkg.json index 77981b0..b989a84 100644 --- a/vcpkg.json +++ b/vcpkg.json @@ -6,6 +6,7 @@ "eigen3", "gtest", "nlohmann-json", + "openssl", "proj" ] }