从零实现RSA加密:MFC项目中的非对称加密算法原理与代码剖析
2026/7/4 0:09:12 网站建设 项目流程

1. 项目概述与核心价值

最近在整理一些老项目的代码,翻到了一个用MFC实现的RSA加密工具。这个项目虽然有些年头了,但里面关于非对称加密的实现思路、大数运算的处理,以及如何在Windows桌面程序中集成密码学功能,现在看来依然很有嚼头。很多朋友一提到RSA,可能首先想到的是用OpenSSL库或者各种语言现成的加密库,一键调用就完事了。这当然没问题,效率高也安全。但如果你真想搞明白RSA到底是怎么“转”起来的,特别是想理解在C++这种相对底层的环境里,如何从零开始处理那些天文数字般的质数和模幂运算,那么亲手在MFC里“造一次轮子”会是一次极佳的学习旅程。

这个项目就是一个完整的“轮子”。它没有依赖外部的加密库,而是自己实现了RSA算法最核心的步骤:密钥生成、加密和解密。当然,这里说的“实现”更侧重于教学和原理验证,帮助我们穿透那些抽象的数学公式,看到代码是如何一步步执行欧几里得算法、扩展欧几里得算法和模幂运算的。对于正在学习密码学、Windows桌面开发(MFC),或者对安全编程感兴趣的朋友来说,通过剖析这个源码,你能获得远比调用一个API更深刻的理解。比如,你会真正明白为什么RSA的公钥可以公开而私钥必须严格保密,也会在调试大数运算的过程中,切身感受到“为什么RSA加密那么慢”以及“为什么需要选择这么大的素数”。

接下来,我就带你一起拆解这个MFC RSA项目的实现,从算法原理到代码细节,再到实际编程中会遇到的那些“坑”,我们逐一过一遍。

2. RSA算法核心原理快速回顾

在深入代码之前,我们必须先统一思想,搞清楚RSA到底在干什么。它不是魔法,而是一套精巧的数学游戏规则。

2.1 非对称加密的基石:公钥与私钥

想象一下,你有一个可以挂锁的箱子(公钥)和一把唯一的钥匙(私钥)。你可以把箱子(公钥)复制无数份,发给任何人。任何人想给你传纸条,就把纸条放进箱子,用箱子自带的锁扣“咔嗒”锁上。这个锁一旦扣上,就只有你手上的那把唯一钥匙(私钥)能打开。发送者不需要有钥匙,他只需要有个箱子就行。这就是非对称加密最直观的比喻。RSA算法就是基于大数分解的极端困难性,来制造这样一对数学上关联的“箱子”和“钥匙”。

2.2 密钥生成的五个数学步骤

这是RSA的核心,也是我们代码将要实现的部分。整个过程可以分解为以下五步:

  1. 选择两个大质数p和q。这是所有安全性的起点。在真实的加密中,p和q都是上百位甚至上千位的十进制数(相当于1024位或2048位二进制)。在我们的演示项目中,为了计算速度,会用小得多的数,比如61和53。
  2. 计算模数nn = p * q。n的长度(比特数)就是RSA密钥的长度。n会被公开,作为公钥的一部分。
  3. 计算欧拉函数φ(n)φ(n) = (p-1) * (q-1)。这个值必须被严格保密,因为它直接关系到私钥的生成。
  4. 选择公钥指数e。e是一个整数,需要满足两个条件:1 < e < φ(n),且eφ(n)互质(即最大公约数gcd(e, φ(n)) = 1)。通常选择65537 (0x10001),因为它二进制表示中1很少,能优化加密运算速度,且是一个质数,很大概率与φ(n)互质。
  5. 计算私钥指数d。d是e关于模φ(n)的模逆元。也就是说,d需要满足:(d * e) % φ(n) = 1。计算d需要用到扩展欧几里得算法。d就是你的私钥核心部分。

至此,你的公钥就是(n, e)对,可以发给全世界。你的私钥就是(n, d)对,必须死死藏好。

2.3 加密与解密过程

  • 加密(用公钥(n, e)): 对于明文消息m(在计算机里,任何数据都可以转化为一个整数),计算密文c:c = m^e mod n
  • 解密(用私钥(n, d)): 对于密文c,计算明文m:m = c^d mod n

这里的m^ec^d都是天文数字,直接计算是不可能的。所以必须依赖高效的模幂运算算法,比如快速幂取模算法。

注意:在实际应用中,RSA很少直接加密原始数据,因为RSA能处理的数据块大小受限于n。通常的做法是:用RSA加密一个对称加密算法(如AES)的密钥,然后用这个对称密钥去加密实际的大数据。这就是常见的“混合加密”体系。

3. MFC项目结构与核心类设计分析

打开这个MFC项目,你会发现它通常包含以下几个关键部分:

  1. 对话框资源:一个典型的MFC对话框程序,包含输入明文/密文的编辑框、显示密钥的控件、以及“生成密钥”、“加密”、“解密”等按钮。
  2. 核心算法类:通常会被封装成一个或多个C++类,例如CRSACRSAAlgorithm。这个类是整个项目的心脏。
  3. 大数表示:RSA涉及的数字远超标准C++数据类型(如long long)的范围。因此,需要一种方式来表示和计算大整数。在这个教学项目中,常见的选择有:
    • 自定义大数类:例如用std::vector<int>或数组来模拟,每个元素代表十进制的一位或若干位(如10000进制)。这是理解大数运算最好的方式,但代码量较大。
    • 使用现有大数库:如GNU MP (GMP) 或 C++ BigInt 库。在更贴近实用的演示中可能会采用。
    • 本项目常见情况:为了简化焦点,很多教学项目会使用CStringstd::string来存储十进制数字字符串,并实现字符串形式的加减乘除模运算。这虽然效率极低,但极其直观,便于调试和观察。

我们假设这个项目采用了一种相对直观的字符串大数表示法。CRSA类的大致骨架如下:

// RSA.h class CRSA { public: CRSA(); ~CRSA(); // 生成密钥对 BOOL GenerateKeys(int nBitLength = 64); // 演示用64位,实际应>=1024 // 获取密钥字符串表示 CString GetPublicKey() const; CString GetPrivateKey() const; // 加密解密 CString Encrypt(const CString& strPlain, const CString& strPubKeyN, const CString& strPubKeyE); CString Decrypt(const CString& strCipher, const CString& strPriKeyN, const CString& strPriKeyD); // 内部工具函数 static CString GenPrime(int bits); // 生成一个指定位数的素数(字符串) static BOOL IsPrime(const CString& strNum); // 米勒-拉宾素性测试 static CString ModPow(const CString& strBase, const CString& strExp, const CString& strMod); // 模幂运算 static CString ModInv(const CString& strE, const CString& strPhi); // 求模逆元(扩展欧几里得) static CString Gcd(const CString& strA, const CString& strB); // 求最大公约数 private: CString m_strP; // 质数p CString m_strQ; // 质数q CString m_strN; // 模数 n = p*q CString m_strPhi; // φ(n) = (p-1)*(q-1) CString m_strE; // 公钥指数 CString m_strD; // 私钥指数 };

4. 核心算法模块的源码实现与难点剖析

接下来,我们深入到最关键的几个函数实现中。这里我会用伪代码和逻辑描述,并指出关键点和易错点。

4.1 大数运算的基础:字符串算术

由于我们假设用CString存储大数,那么最基本的加、减、乘、除、取模都需要自己实现。这通常是项目中最繁琐的部分。

  • 加法/减法:模拟竖式计算,注意进位和借位。
  • 乘法:效率较低的可以是模拟竖式(O(n^2)),好一点可以实现Karatsuba算法(O(n^1.585))。在演示项目中,简单实现即可。
  • 除法/取模:这是最复杂的。需要实现大数除以大数,返回商和余数。通常模拟长除法。
// 示例:大数比较(字符串形式,假设都是正整数,无前导零) int CompareBigInt(const CString& strA, const CString& strB) { int lenA = strA.GetLength(); int lenB = strB.GetLength(); if (lenA != lenB) { return (lenA > lenB) ? 1 : -1; } return strA.Compare(strB); // 同长度直接字典序比较 } // 示例:大数减法(strA >= strB) CString SubtractBigInt(const CString& strA, const CString& strB) { // 实现略,注意借位和结果前导零的清理 }

实操心得:在调试大数运算时,务必从最小的数字开始测试,比如“123” * “45”,并和计算器结果对比。前导零的处理是个大坑,一定要在运算后写一个TrimLeadingZeros函数来清理,否则比较和后续运算会出错。

4.2 素性测试:如何找到大质数

GenPrime函数的目标是生成一个大概率是质数的大数。完全确定一个超大数是否是质数非常耗时,所以密码学中普遍使用概率性测试,最常用的是米勒-拉宾素性测试

它的原理基于费马小定理和一些二次探测定理。简单说,对于一个待测数n,随机选择一些基数a进行测试。如果所有测试都通过,那么n极大概率是质数。测试次数越多,出错概率越低(可低至忽略不计)。

BOOL CRSA::IsPrime(const CString& strNum) { // 处理小数字 if (strNum == "2" || strNum == "3") return TRUE; if (strNum == "1" || (strNum.GetAt(strNum.GetLength()-1) - '0') % 2 == 0) return FALSE; // 偶数不是质数(2除外) // 将strNum转换为便于计算的大数结构,假设为BigNum BigNum n(strNum); BigNum nMinusOne = n - 1; // 将 n-1 写成 d * 2^s 的形式(d是奇数) BigNum d = nMinusOne; long s = 0; while (d % 2 == 0) { d = d / 2; s++; } // 进行k轮米勒-拉宾测试,k越大越准 for (int i = 0; i < kMillerRabinRounds; ++i) { // 随机生成一个 [2, n-2] 之间的基数 a BigNum a = GenRandomBigNum(2, n - 2); BigNum x = ModPow(a, d, n); // 计算 a^d mod n if (x == 1 || x == nMinusOne) { continue; // 本轮测试通过 } BOOL bContinue = FALSE; for (long r = 1; r < s; ++r) { x = (x * x) % n; // 平方取模 if (x == nMinusOne) { bContinue = TRUE; break; } } if (bContinue) continue; return FALSE; // 一定是合数 } return TRUE; // 很可能是质数 }

注意事项:随机数生成器的质量在这里至关重要。在MFC中,可以使用CryptGenRandom这个Windows CryptoAPI函数来获得密码学安全的随机数,这比标准的rand()函数要安全得多。对于演示项目,rand()尚可,但要知道其局限性。

4.3 扩展欧几里得算法求模逆元

这是计算私钥d的关键。给定e和φ(n),我们需要求d,使得(d * e) % φ(n) = 1。扩展欧几里得算法在求最大公约数(gcd)的同时,能求出贝祖等式s * e + t * φ(n) = gcd(e, φ(n))中的系数s和t。

因为e与φ(n)互质,所以gcd(e, φ(n)) = 1。那么等式变为s * e + t * φ(n) = 1。对这个等式两边同时模φ(n),得到(s * e) % φ(n) = 1。因此,s就是e模φ(n)的逆元d。注意s可能为负数,需要加上φ(n)将其调整到正数范围。

CString CRSA::ModInv(const CString& strE, const CString& strPhi) { // 使用扩展欧几里得算法 // 初始化 BigNum a = strE, b = strPhi; BigNum s0 = 1, s1 = 0; // 系数s BigNum t0 = 0, t1 = 1; // 系数t BigNum q, r, s2, t2; while (!b.IsZero()) { q = a / b; r = a % b; s2 = s0 - q * s1; t2 = t0 - q * t1; // 更新变量 a = b; b = r; s0 = s1; s1 = s2; t0 = t1; t1 = t2; } // 循环结束后,a是gcd, s0 * strE + t0 * strPhi = a // 因为strE与strPhi互质,所以a应为1 // s0 即为模逆元,但可能为负 CString strD = s0.ToString(); if (s0.IsNegative()) { strD = (s0 + BigNum(strPhi)).ToString(); // 转换为正数 } return strD; }

4.4 快速模幂运算:加密解密的引擎

无论是加密的c = m^e mod n还是解密的m = c^d mod n,核心都是模幂运算。直接计算再取模是不可能的。快速幂算法(平方乘算法)能将复杂度从O(e)降到O(log e)。

其原理基于:a^b mod n,将指数b用二进制表示,例如b = 13 (1101),那么a^13 = a^(8) * a^(4) * a^(1)。我们可以通过反复平方a,并根据b的二进制位决定是否乘到结果中。

CString CRSA::ModPow(const CString& strBase, const CString& strExp, const CString& strMod) { BigNum result = 1; BigNum base = strBase % strMod; // 先取一次模,减少后续计算量 BigNum exp = strExp; while (!exp.IsZero()) { // 如果当前二进制位为1 if (exp.IsOdd()) { // 判断exp是否为奇数,即二进制最低位是否为1 result = (result * base) % strMod; } // 将base平方 base = (base * base) % strMod; // 指数右移一位(除以2) exp = exp / 2; } return result.ToString(); }

核心技巧:在每次乘法后立即取模(% strMod),是控制中间结果大小、防止溢出的关键。即使我们使用大数类,这个操作也能极大提升运算速度和降低内存消耗。

5. MFC对话框的集成与数据流

算法类实现后,在MFC对话框中的集成就是标准的消息映射和控件交互了。

  1. “生成密钥”按钮响应函数
    void CRSADlg::OnBnClickedButtonGenkey() { UpdateData(TRUE); // 从控件获取数据,比如密钥长度 m_rsaObj.GenerateKeys(m_nKeyBits); // 调用核心类生成密钥 m_strPublicKey = m_rsaObj.GetPublicKey(); // 格式如 "N=3233;E=17" m_strPrivateKey = m_rsaObj.GetPrivateKey(); // 格式如 "N=3233;D=2753" UpdateData(FALSE); // 更新控件显示 }
  2. “加密”按钮响应函数
    void CRSADlg::OnBnClickedButtonEncrypt() { UpdateData(TRUE); // 从界面获取公钥N和E(可能来自输入或上一次生成) CString strN, strE; ParsePublicKey(m_strPublicKey, strN, strE); // 解析字符串 // 明文可能需要先进行编码(如转换为大整数) CString strPlain = m_strInputText; BigNum m = StringToBigNum(strPlain); // 需要实现一个编码函数 // 加密 CString strCipher = m_rsaObj.Encrypt(strPlain, strN, strE); m_strOutputText = strCipher; UpdateData(FALSE); }
  3. “解密”按钮响应函数:逻辑类似,使用私钥N和D。

一个关键问题:数据编码RSA算法操作的是整数。但我们要加密的是字符串(文本)。这就需要一种编码方案将字符串转换为一个大整数(m),并且要确保m < n。简单的方案可以是:

  • 将每个字符视为一个字节(0-255)。
  • 将这些字节拼接起来,视为一个以256为基的大数。 例如,字符串“Hi”的ASCII码是72, 105,可以编码为整数72 * 256 + 105 = 18537。解密后再反向解码。在演示项目中,这种简单编码足以说明问题。但在现实中,需要使用PKCS#1等填充标准,既是为了编码,也是为了增加安全性。

6. 常见问题、调试技巧与安全警示

在实现和运行这个项目时,你几乎一定会遇到下面这些问题。

6.1 运算速度极慢

现象:点击“生成密钥”或“加密/解密”后,程序卡住很久,甚至无响应。原因

  1. 使用的素数p和q太小,导致n太小,容易被破解。但为了演示,我们常常故意用小素数,这不是主因。
  2. 核心原因是字符串大数运算效率低下。每一次加减乘除都在操作字符串,复杂度很高。尤其是模幂运算中的乘法和取模,指数e或d很大时(即使是65537),循环次数是log2(e)次,每次循环包含大数乘法和大数取模,非常慢。
  3. 素性测试IsPrime如果测试轮数多,也会很慢。

解决方案

  • 教学目的:接受它的慢。这是理解算法代价的一部分。可以将密钥长度降到32位或48位来快速验证流程。
  • 追求实用:替换大数运算库。集成GMP(GNU Multiple Precision Arithmetic Library)是正解。GMP用高度优化的汇编代码实现大数运算,速度是天壤之别。在MFC项目中集成GMP需要配置其库文件和头文件。

6.2 加密解密结果不对

现象:加密后的密文,解密回来不是原文。排查步骤

  1. 检查编码/解码:这是最容易出错的地方。确保StringToBigNumBigNumToString函数是完全可逆的。添加日志,输出编码后的整数m,加密后的c,解密后的m‘,最后解码的字符串。
  2. 检查密钥一致性:确保加密用的(n,e)和解密用的(n,d)是同一对密钥。在界面上手动复制粘贴时容易出错。最好在生成密钥后,将n、e、d分别显示出来核对。
  3. 验证核心算法:写一个简单的单元测试,不用界面,直接用代码测试。
    void TestRSA() { CRSA rsa; rsa.GenerateKeys(32); // 用小密钥测试 CString pubN, pubE, priN, priD; // ... 获取密钥 CString plain = "Test123"; CString cipher = rsa.Encrypt(plain, pubN, pubE); CString decrypted = rsa.Decrypt(cipher, priN, priD); ASSERT(plain == decrypted); // 如果失败,进入调试 }
  4. 调试模幂运算:在ModPow函数内部添加日志,输出每一轮循环的baseresultexp值,与手算一个小例子对比。
  5. 检查大数运算的基础函数:尤其是取模运算。实现一个BigNum Mod(const BigNum& a, const BigNum& b)函数,并用大量随机小数据测试其正确性。除法/取模的实现错误是万恶之源。

6.3 内存泄漏与崩溃

现象:程序运行一段时间后崩溃,或内存占用不断增长。原因:在实现自定义大数类(如用int*动态数组)时,没有遵循Rule of Three(拷贝构造函数、拷贝赋值运算符、析构函数)。在MFC的CString操作中,如果频繁进行MidLeft等操作创建临时对象,也可能有开销,但通常不会导致崩溃。

解决方案

  • 如果自定义大数类,务必重写析构函数(释放内存)、拷贝构造函数和拷贝赋值运算符(深拷贝)。
  • 使用std::vector<int>来管理动态数组,可以省去很多内存管理的麻烦。
  • 在关键算法函数中,使用性能分析工具检查热点和内存分配。

6.4 关于安全的严重警告

请务必牢记,这个项目及其代码仅用于学习和理解RSA算法原理,绝对不可用于任何真实的、需要安全保护的场景!

  1. 随机数不安全:自己实现的随机数生成器或使用rand(),都是伪随机,可预测。
  2. 密钥生成太弱:演示用的素数太小,瞬间可被分解。即使改用大素数,我们的素性测试也可能不够健壮。
  3. 没有填充方案:直接对编码后的整数进行加密,是“教科书式RSA”(Textbook RSA),存在多种致命攻击方式(如明文猜测攻击、共模攻击等)。PKCS#1等填充标准是必须的。
  4. 侧信道攻击:我们的代码执行时间、内存访问模式可能泄露密钥信息。专业的加密库会使用恒定时间算法等技术来防御。
  5. 大数运算库:自己写的大数运算库可能存在边界错误,导致计算错误或缓冲区溢出漏洞。

正确的做法是:在实际项目中,如果需要使用RSA,请使用久经考验的成熟库,如:

  • C/C++:OpenSSL, Crypto++, libsodium
  • Windows原生:Microsoft CryptoAPI (CAPI) 或 Cryptography API: Next Generation (CNG) 这些库经过了无数安全专家的审查和多年的实战测试,提供了正确、安全、高效的实现。

7. 项目扩展与优化思路

尽管不能用于生产,但这个学习项目仍有很大的改进和扩展空间,能让你学到更多:

  1. 集成GMP库:这是最有价值的优化。将项目中所有CString类型的大数替换为GMP的mpz_t类型。你会立刻感受到速度上数百倍甚至上千倍的提升,并且可以轻松操作1024位以上的大数。你需要学习GMP的基本初始化、赋值、运算和释放函数。
  2. 实现更高效的素数生成:使用更快的随机数生成器(如Windows的BCryptGenRandom),并结合更优化的搜索策略(如只生成奇数,跳过明显的小质数倍数)。
  3. 增加填充方案:尝试实现最简单的PKCS#1 v1.5填充。理解为什么需要填充,以及填充如何防止某些攻击。
  4. 支持文件加密:由于RSA速度慢,实现混合加密体系。用RSA加密一个随机生成的AES密钥,然后用这个AES密钥去加密大文件。这是真实世界的标准做法。
  5. 改进UI:增加密钥长度选择、进度条显示(用于长时间密钥生成)、加密文件的选择对话框等。
  6. 编写完整的单元测试:为每一个核心函数(Add,Multiply,Mod,ModPow,IsPrime)编写测试用例,确保其正确性。这是保证代码质量的基础。

通过这个MFC RSA项目的解剖,我们从最高的算法概念,一直下沉到最底层的字符串加减乘除,完成了一次对非对称加密的深度穿越。你会发现,最复杂的不是某个高深的数学定理,而是如何将那些定理准确、稳定、高效地翻译成计算机代码,并处理好所有的边界情况。这个过程充满挑战,但也正是编程的乐趣所在。当你最后看到自己输入的文字,经过一系列复杂的变换后,变成一串看似无意义的数字,又能被准确地还原回来时,那种成就感,是单纯调用一个加密API无法比拟的。这或许就是“知其然,更知其所以然”的力量。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询