ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

密码学课程设计实战:从BigInt到RSA与ElGamal的C++实现

密码学课程设计实战:从BigInt到RSA与ElGamal的C++实现 简介一份面向密码学课程设计的C/C源码与工程文件集合围绕五个典型编程题目展开涵盖凯撒与替换密码、对称加密、非对称加密、哈希函数与消息认证、数字签名等核心知识点适合高校学生完成课程实验、撰写设计报告或复习备考。压缩包共85个文件以C源文件16个cpp、头文件6个h和编译生成的目标文件18个o为主另含Code::Blocks工程配置、可执行程序及依赖文件整体仅2.5MB代码与可运行程序并存便于直接查看效果和调试。目录按“第一题”至“第五题”分别组织每个题目下都有main.cpp及对应工程文件内容预览中可见大整数运算、MD5、RSA、ElGamal等模块体现出算法实现与工程实践的完整结合。目前已有793人学习/下载适合需要完整课设方案、可运行代码或算法讲解的读者可直接对照参考快速理解密码学算法的实现思路与细节。1. 从凯撒到 ElGamal一份密码学课设的五块积木拿到这份压缩包第一反应通常是“把五个题目的工程都编译一遍”。但跑通只是入场券transform、md5、fangshe、RSA、elgamal五个工程恰好把古典密码、哈希、非对称加密、数字签名串成了一条递进链路。前两道题考的是模运算直觉第四题逼你写大数运算第五题又让你理解随机数在加密里的角色。这份课设真正的价值是让你在一周内把教科书公式变成能跑的 C/C 代码——这比抄一份报告有用得多。下文按“先理论、后源码、再排错”的顺序拆开每个工程代码尽量贴着原始目录的模块划分来写方便你对着源码定位。2. BigInt 与模幂RSA/ElGamal 共同依赖的大数地基2.1 为什么 C 内置类型撑不起非对称加密transform和fangshe两道古典题本质都是(a*x b) mod 26这种小模数线性变换int足够。但到了第四题的 RSA 和第五题的 ElGamal模数n和素数p动辄 512 位甚至 1024 位unsigned long long连一个 64 位素数都塞不下更别提x^d mod n这种中间结果会膨胀到上千位的运算。C/C 标准库没有内置大整数所以课设包里出现了BigInt.h、bigint、bigint2、bigint3四套实现。其中bigint3目录下带着miracl.h和mirdef.h说明老师接受直接调外部大数库而BigInt.h和bigint2是手写实现适合用来讲清楚原理。理解大数运算的核心只有两件事怎么存、怎么乘。2.2 一版可直接复用的 BigInt 存储与乘法常见做法是用vectoruint32_t按小端存储val[0]放最低 32 位。选uint32_t做基底是因为两个 32 位数相乘最大约2^64 - 2^33 1刚好能被uint64_t接住不会溢出。#include vector #include cstdint #include string class BigInt { public: std::vectoruint32_t val; // 小端val[0] 是最低 32 位 BigInt(uint64_t x 0) { while (x) { val.push_back(x 0xFFFFFFFF); x 32; } if (val.empty()) val.push_back(0); } // 竖式乘法res[ij] a[i] * b[j] BigInt operator*(const BigInt o) const { BigInt res; res.val.assign(val.size() o.val.size() 1, 0); for (size_t i 0; i val.size(); i) { uint64_t carry 0; for (size_t j 0; j o.val.size(); j) { uint64_t cur (uint64_t)res.val[i j] (uint64_t)val[i] * o.val[j] carry; res.val[i j] (uint32_t)(cur 0xFFFFFFFF); carry cur 32; } res.val[i o.val.size()] (uint32_t)carry; } while (res.val.size() 1 res.val.back() 0) res.val.pop_back(); // 去掉前导 0 return res; } // 十进制字符串转 BigInt读 RSA 公钥时常用 static BigInt fromDec(const std::string s) { BigInt r(0); for (char c : s) { r r * BigInt(10) BigInt((uint64_t)(c - 0)); } return r; } };乘法是 O(n·m) 的竖式展开内层循环把a[i]和b[j]的 64 位乘积累加到res[ij]低 32 位写回高 32 位作为carry留给下一位。代码最后清掉前导零保证val里没有多余的 0否则比较和输出都会出错。fromDec逐位乘 10 再加用来把n、e这种十进制公钥参数转成内部数组注意这里依赖operator实际课设代码里还要补一个operator和取模运算。2.3 模幂把指数二进制展开大数乘法有了RSA 解密c^d mod n和 ElGamal 加密g^k mod p本质是同一个操作模幂。直接循环乘 d 次是天文数字必须用反复平方法把指数按二进制位展开复杂度降到 O(log d) 次乘法。// 快速模幂base^exp mod mod BigInt modPow(BigInt base, BigInt exp, const BigInt mod) { BigInt res(1); base base % mod; while (!(exp.val.size() 1 exp.val[0] 0)) { if (exp.val[0] 1) // 当前二进制位是 1 res (res * base) % mod; // 乘入结果 base (base * base) % mod; // 底数平方 exp exp / BigInt(2); // 右移一位 } return res; }每次循环先看指数最低位是不是 1是就把当前底数乘进结果然后底数自平方指数除 2。注意base % mod一开始就做一次防止底数大于模数导致后续平方越界。这里依赖大数除法取模课设里实现除法最省事的方式是二进制长除法——逐位左移被除数够减就试商 1不够就试商 0缺点是慢但 512 位数据量完全跑得动。2.4 参数选择与常见误区基底uint32_t乘法看似安全但如果你把基底换成uint64_t两个数相乘立刻溢出uint64_t这是最常见的翻车点。另一个坑是exp / BigInt(2)这一步除法要重载正确否则循环退不出来。bigint2和bigint3分别代表了“完全手写”和“接 MIRACL 库”两种路线手写版适合讲原理MIRACL 版适合做性能测试对比。我一般建议两个都保留课设报告里写“自研大数库与工业库的乘法耗时对比”比只贴代码高出一个档次。3. MD5 的 C/C 落地从填充位到四轮压缩函数3.1 MD5 工程里你能看到什么第二题md5目录结构很典型MD5.h声明接口main.cpp负责调用和打印cbp/depend是 Code::Blocks 工程文件。课设版 MD5 通常要求实现两个能力任意长度消息的哈希计算以及和标准工具输出一致的十六进制摘要。MD5 的核心套路是固定的填充、分组、四轮压缩、输出。下面代码按这个顺序拆。3.2 填充让长度对齐到 512 位MD5 按 512 位64 字节分组处理最后一块必须补到 56 字节留下 8 字节存原始比特长度。#include vector #include cstdint #include cstring // 把任意输入填充为标准 MD5 分块 std::vectoruint8_t md5Pad(const uint8_t* data, size_t len) { std::vectoruint8_t out(data, data len); uint64_t bitLen (uint64_t)len * 8; // 注意字节数转比特数 out.push_back(0x80); // 先补一个 1 while (out.size() % 64 ! 56) { // 补 0 直到剩余 8 字节 out.push_back(0x00); } for (int i 0; i 8; i) { // 小端写入原始长度 out.push_back((bitLen (8 * i)) 0xFF); } return out; }填充规则是硬性要求无论原消息多长都必须补一个0x80再补零到len % 64 56。最后 8 字节存的是比特长度不是字节长度len * 8这一步漏掉直接全错。长度字段按小端逐字节写入因为 MD5 规范里所有多字节整数都是小端。这里有个隐蔽点如果原始消息长度正好是 56 字节仍然要补满 64 字节再追加长度块不能省。3.3 四轮压缩函数与主循环填充后的数据按 64 字节分组每组进入压缩函数。压缩函数维护a、b、c、d四个 32 位状态字初始值是固定的幻数0x67452301、0xefcdab89、0x98badcfe、0x10325476。四轮共 64 步每步结构相同区别在非线性函数和左移位数轮次非线性函数消息下标规律 g左移位数1F(x,y,z) (x y) | (~x z)g i7, 12, 17, 22 循环2G(x,y,z) (x z) | (y ~z)g (5*i 1) % 165, 9, 14, 20 循环3H(x,y,z) x ^ y ^ zg (3*i 5) % 164, 11, 16, 23 循环4I(x,y,z) y ^ (x | ~z)g (7*i) % 166, 10, 15, 21 循环四轮主循环的每一轮 16 步步与步之间通过循环移位交换状态字这也是初学者最容易抄错的地方static uint32_t leftRotate(uint32_t x, int c) { return (x c) | (x (32 - c)); } // 第 i 步压缩i 从 0 到 63M 是当前分组的 16 个 32 位字 uint32_t F (b c) | (~b d); // 轮函数按轮次切换 uint32_t g (轮次公式确定的下标); uint32_t tmp d; d c; c b; b b leftRotate(a F K[i] M[g], s[i]); a tmp;每一步的K[i]是 64 个固定常量来自正弦函数的小数部分直接查表即可不需要现场计算。位移表s[i]按上表循环。注意状态字的轮换方式是“右旋”d - c - b - a而不是四行同时独立计算漏掉这个顺序输出就会完全错掉。M[g]的取下标规律是每轮不同的排列目的是让每个消息字节在四轮里都被充分混合。3.4 输出字节序与测试向量压缩完所有分组后a、b、c、d就是 16 字节摘要。输出时要把每个状态字按小端拆成 4 个字节再转十六进制void printDigest(uint32_t digest[4]) { for (int i 0; i 4; i) { uint32_t w digest[i]; for (int j 0; j 4; j) { printf(%02x, w 0xFF); w 8; // 低字节先输出 } } printf(\n); }这是 MD5 和很多网络字节序算法不一样的地方内部状态是小端直接按大端打印会得到完全不同的字符串。验证用标准向量MD5(abc)必须是900150983cd24fb0d6963f7d28e17f72MD5()必须是d41d8cd98f00b204e9800998ecf8427e。建议在main.cpp里直接写断言比肉眼比对可靠。4. RSA 与 ElGamal 的实现差异密钥生成、加解密与签名4.1 RSA 密钥生成扩展欧几里得求私钥指数第四题的RSA工程配了BigInt.h说明核心运算全建立在大数之上。RSA 密钥生成的教科书流程选两个大素数p、q计算n p*q、φ (p-1)*(q-1)选公钥指数e常见65537再用扩展欧几里得求d ≡ e^{-1} mod φ。// 扩展欧几里得求 a 在模 m 下的逆元 BigInt modinv(const BigInt a, const BigInt m) { BigInt t(0), newT(1); BigInt r m, newR a % m; while (!(newR.val.size() 1 newR.val[0] 0)) { BigInt q r / newR; // 大数除法 BigInt tmp t; t newT; newT tmp - q * newT; tmp r; r newR; newR tmp - q * newR; } if (r BigInt(1)) throw std::runtime_error(not invertible); if (t BigInt(0)) t t m; return t; }算法每轮更新(t, newT)和(r, newR)终止条件是余数为 0。如果最后的r大于 1说明a和m不互素逆元不存在——对应到 RSA 就是选的e和φ不互素必须重新选e。课设里很多人忘记检查这个条件导致解密结果乱码。注意这里用到了大数除法r / newR如果 2.3 节里除法没实现密钥生成会直接卡死。4.2 加解密与签名一个模幂函数通吃有了modPowRSA 的加解密、签名验证就只剩一行// 加密c m^e mod n BigInt rsaEncrypt(const BigInt m, const BigInt e, const BigInt n) { return modPow(m, e, n); } // 解密m c^d mod n BigInt rsaDecrypt(const BigInt c, const BigInt d, const BigInt n) { return modPow(c, d, n); } // 签名s H(m)^d mod n验证H(m) s^e mod n BigInt rsaSign(const BigInt hash, const BigInt d, const BigInt n) { return modPow(hash, d, n); }四个操作完全是同一套模幂区别只在底数和指数是谁。实操中有一个硬约束消息m必须满足0 m n如果明文太长要按块加密或先做混合加密。课设里最常见的错误是直接std::string转 BigInt 然后加密明文字节数一旦超过n的字节数解密必然失败。我一般会在main.cpp里做个长度检查n有 64 字节时每块明文最多 53 字节预留 PKCS#1 填充位超出就分段。4.3 ElGamal 的随机数 k密文随机性的来源第五题elgamal工程同样依赖MD5.h和bi.cpp说明这个版本把哈希和 BigInt 都揉进来了。ElGamal 密钥生成选大素数p、原根g、私钥x公钥y g^x mod p。加密时必须选一个临时随机数k密文是二元组// 加密选随机 k计算两个密文分量 BigInt k randomInRange(BigInt(2), p - 2); // 每次加密必须重新生成 BigInt c1 modPow(g, k, p); BigInt c2 (m * modPow(y, k, p)) % p; // 解密m c2 / c1^x mod p BigInt s modPow(c1, x, p); BigInt m2 (c2 * modinv(s, p)) % p;对比 RSA 一眼就能看出差别RSA 加密是确定性的同样的明文和密钥每次密文都一样ElGamal 因为有随机k同样的明文每次密文都不同。这是 ElGamal 的签名和加密优势也是最大的坑——k如果被重复使用攻击者直接用两次密文相除就能恢复明文k如果被泄露私钥x可由x (m - k*c1) * c2^{-1} mod (p-1)反推。课设报告里必须写这一条这是 5 年以上工程师也会重点追问的点。4.4 RSA 与 ElGamal 的对比与报告要点对比项RSAElGamal密钥结构公钥 (n,e)私钥 (n,d)公钥 (p,g,y)私钥 (p,g,x)加密运算c m^e mod nc1 g^k mod pc2 m*y^k mod p解密运算m c^d mod nm c2 * (c1^x)^{-1} mod p密文长度与 n 等长是 p 的两倍随机数依赖无裸加密时每次加密必须新 k软考信息安全工程师的密码学计算题常考的就是这两类题型给p、q、e求d再解密或者给 ElGamal 的p、g、x、k算c1、c2再解密。课设源码里每一步都盯住了这类题等于做过一遍编程实现考试时很难再错。报告的“安全性分析”章节RSA 可以写小指数攻击和共模攻击ElGamal 写k重用攻击这两块都是现成的理论素材。5. 用测试向量和 openssl 给课设代码做交叉验证5.1 用测试向量把哈希代码钉死MD5 的标准测试向量是免费的调试工具不用写单测框架直接在main.cpp里加断言assert(md5Hex() d41d8cd98f00b204e9800998ecf8427e); assert(md5Hex(abc) 900150983cd24fb0d6963f7d28e17f72); assert(md5Hex(message digest) f96b697d7cb7938d525a2f31aaf161d0);三个向量分别覆盖空消息、短消息、中等长度消息能定位到填充逻辑和压缩函数的绝大部分问题。如果第一个对、第二个错问题几乎一定出在填充的长度字段字节序上如果前两个对、第三个错检查分组循环是不是正确处理了多块数据。5.2 用 openssl 反向验证 RSA 参数RSA 的密钥参数可以用 openssl 生成后提取出来和你自己代码的结果比对openssl genrsa -out rsa_private.pem 512 openssl rsa -in rsa_private.pem -text -noout openssl rsa -in rsa_private.pem -pubout -out rsa_public.pem openssl pkeyutl -encrypt -pubin -inkey rsa_public.pem -in msg.txt -out cipher.bin-text输出里能看到n、e、d、p、q的十六进制值直接复制进你自己代码的fromHex或fromDec跑一遍加解密能对上就是对的。注意 512 位密钥只用于课设演示正式场景至少 2048 位。如果发现 openssl 加密后你解不开先查明文长度再查你的模幂函数是不是漏了最后一步% n。5.3 高频翻车点与修法现象根因修法MD5 输出全是 0幻数初始化写错或状态字轮换方向错对照 3.3 节检查a、b、c、d赋值顺序ElGamal 每次加密结果相同随机数 k 写成了固定值每次加密重新取随机数并做范围检查密钥生成极慢用试除法判断大素数换 Miller-Rabin 素性检测先筛小素数再迭代解密结果是乱码消息长度超过 n 或填充位没预留按块加密每块明文长度控制在 n 的字节数减 11MIRACL 链接报错库没编译或路径不对确认miracl.a已生成工程设置里加库路径最后一招在main.cpp顶部加一个#define DEBUG所有中间量a、b、c、d或 RSA 的中间变量都打印出来对照手工演算的第一步分组数据逐字节核对定位速度比瞎改快一个数量级。本文还有配套的精品资源点击获取
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进