
前阵子有个读者私信问我课程设计想做图像加密又不想一头扎进AES那一大堆轮函数里想找个“既有公钥密码又有古典密码”的方案我第一个想到的就是标题这套组合椭圆曲线Diffie-HellmanECDH密钥协商配希尔密码Hill Cipher图像加解密。思路其实很直白先用椭圆曲线上离散对数问题协商出一个双方共享的秘密点把它作为种子生成一张可逆矩阵再拿这张矩阵对图像灰度值做分块矩阵乘法和模运算完成加密和解密。整套用MATLAB实现编码量不大但原理覆盖得特别全既能体现非对称密码的密钥协商又能体现矩阵密码的加解密机制拿来写论文、做答辩演示都很合适。这篇文章我会从方案选型讲起把ECDH涉及的点加、点乘、模逆希尔密码涉及的可逆矩阵生成、模256逆矩阵求解以及图像分块加密、直方图和相关系数验证、性能优化这些内容全部过一遍代码都是我在R2021a上实测跑通的坑也会一并交代清楚。适合正在做密码学课设、图像信息安全方向研究或者想把手里的MATLAB能力往密码方向延伸一点的人参考。1. 方案整体设计与选型逻辑1.1 这套混合加密到底解决了什么问题图像加密和文本加密有个很大的不同图像数据量大、相邻像素之间冗余度高而且像素值范围固定在0到255。你要是拿一段文本加密的思路去处理图像往往发现加密后的数据根本组不成一张“看起来合理”的密文图。这套方案里ECDH负责的是“密钥怎么安全地送到对方手上”希尔密码负责的是“拿到密钥后怎么把图像像素矩阵打乱”。两者结合就形成了一个典型的混合加密闭环非对称密码做密钥协商对称密码做数据加密。很多初学者会问为什么不能直接用ECDH去加密图像本身这里有个关键点椭圆曲线上的运算本质上是在一群有限域的点之间做标量乘法直接加密大块图像数据效率太低而且编码麻烦。更合理的做法是像TLS那样用非对称密码协商出一个临时会话密钥后续数据全部用对称密码处理。这也正是这套方案的设计精髓也是我在实际项目里觉得最值得讲给新手听的地方。1.2 为什么密钥协商选ECDH而不是RSA选椭圆曲线Diffie-Hellman首要原因是它跟RSA这类公钥密码比起来在同等安全强度下密钥长度短得多。比如安全性要达到AES-128的水平RSA需要3072位模数而椭圆曲线只要256位左右的素数域参数。在MATLAB这种本身不擅长处理超大整数的环境里短参数意味着你可以用内置的double类型近似模拟点运算教学演示会顺畅很多。当然我说的是原理演示场景真实生产环境还得靠OpenSSL这类成熟密码库去实现标准曲线这一点后文会反复强调。另一个原因跟题目本身有关Diffie-Hellman协议解决的是“两个人在公开信道上协商一个共同秘密”的问题它天然适合用在图像加密这种需要双方提前约定密钥的场景。RSA虽然也能做密钥传输但需要额外处理填充和加密模式的细节教学成本反而更高。ECDH的流程直观双方各自生成私钥算出公钥发给对方再用“自己的私钥乘对方的公钥”得到同一个共享点。整个推导过程用椭圆曲线的结合律就能讲明白适合在博文和论文里展开。1.3 为什么图像加密选希尔密码而不是AES希尔密码是经典的分组密码它在数学上就是一个线性变换把m个明文像素组成一个列向量P用m×m密钥矩阵K去乘得到密文向量C K·P mod n。图像本身就是二维矩阵天然可以拆成多个等长的像素分组和希尔密码的分组模型严丝合缝。再加上像素值范围是0到255模数直接取256就能保持数值范围不变整个加密过程在MATLAB里几行代码就能完成可视化和调参都非常方便。相比AES希尔密码的劣势很明显它是一个线性代数结构抗已知明文攻击的能力很弱几个明文密文对可能就把密钥矩阵解出来。但优势在于它足够直观几乎所有密码学教材都会讲用MATLAB实现还能顺便复习一遍矩阵运算、模逆和线性代数。就课程设计和论文而言把AES调包出来跑一遍远不如手写希尔密码来得有说服力。所以我的结论是这套方案不是拿去做真实生产系统而是为了让“非对称协商对称加密”这个框架跑得透明、讲得清楚。2. 核心算法原理与MATLAB基础实现2.1 有限域上的椭圆曲线点运算椭圆曲线密码的核心不是那条连续的椭圆曲线而是定义在有限域GF(p)上的离散点集。简单理解把所有坐标和运算都限制在模p的整数范围内曲线方程写成y² ≡ x³ ax b (mod p)其中p是大素数。在这个点集上定义“点加”和“倍点”运算后它们构成一个群。所谓“私钥推公钥”就是给定基点G和整数k计算kG G G … G这个标量乘法计算很容易但反过来由kG和G求k目前只能靠离散对数算法暴力解复杂度极高。MATLAB里实现点运算有几个前置工具必须先写好。第一个是模p下的乘法逆元因为点加涉及斜率斜率公式里带分母在有限域里“除以一个数”就是“乘以它的模逆”。p是素数时可以用费马小定理求逆a⁻¹ ≡ a^(p−2) (mod p)实现起来非常干净。我用的是快速幂算法。function r pow_mod(a, e, n) r 1; a mod(a, n); while e 0 if bitand(e, 1) r mod(r * a, n); end a mod(a * a, n); e bitshift(e, -1); end end function inv modinv_fermat(a, p) if a 0 a mod(a, p); end inv pow_mod(a, p - 2, p); end然后是点加运算。两个不同点相加时斜率是两点连线的斜率同一个点自身相加时斜率用该点的切线公式λ (3x₁² a) / (2y₁)。处理时特别注意分母的模逆运算以及无穷远点O相当于群的单位元。我习惯用x inf代表无穷远点代码里统一处理。function [x3, y3] ecc_add(x1, y1, x2, y2, a, p) if isinf(x1) x3 x2; y3 y2; return; end if isinf(x2) x3 x1; y3 y1; return; end if x1 x2 mod(y1 y2, p) 0 x3 inf; y3 inf; return; end if x1 x2 y1 y2 y1 ~ 0 lam mod((3 * x1 * x1 a) * modinv_fermat(2 * y1, p), p); else lam mod((y2 - y1) * modinv_fermat(x2 - x1, p), p); end x3 mod(lam * lam - x1 - x2, p); y3 mod(lam * (x1 - x3) - y1, p); end标量乘法我用double-and-add也就是把k写成二进制逐位处理。当前结果遇到1就加一次基点同时基点不断倍点复杂度是O(log k)。这个过程和快速幂的思想一样理解了快速幂就能理解它。function [rx, ry] ecc_mul(k, x, y, a, p) rx inf; ry inf; tx x; ty y; while k 0 if mod(k, 2) 1 [rx, ry] ecc_add(rx, ry, tx, ty, a, p); end [tx, ty] ecc_add(tx, ty, tx, ty, a, p); k floor(k / 2); end end写这套代码时有三个细节特别容易踩坑第一mod函数和rem函数在负数处理上行为不同有限域运算必须统一用mod否则斜率算出来是负的后面全乱第二double类型乘法可能引入浮点误差所以坐标不能取得太大这也是为什么我教学演示用p97这种小素数域第三无限远点必须单独处理否则点加运算遇到两个互逆点会直接报错。2.2 ECDH密钥协商的完整流程ECDH的流程说穿了就四步。第一步双方协商一套公共参数包括素数p、曲线系数a和b、基点G以及G的阶n。第二步A方生成随机私钥dA计算公钥PA dA·GB方同样生成dB和PB。第三步双方在公开信道上交换公钥PA、PB。第四步A计算S dA·PB dA·dB·GB计算S dB·PA dB·dA·G由于椭圆曲线群满足结合律两边得到的S是同一个点。这一步里最关键也最容易被忽略的是拿到对方公钥后必须先验证这个点确实在曲线上即验证y² ≡ x³ ax b (mod p)。如果不做这一步攻击者可以伪造一个不在曲线上的点诱导对方用这个伪造点做标量乘法导致最终协商出来的密钥不可控。历史上很多Diffie-Hellman实现被攻击根源之一就是对输入公钥缺少有效性验证比如早年某些协议实现爆出过资源管理错误漏洞攻击者发送大量非法点导致CPU密集运算最后把服务拖垮。这类问题在2002年前后就有公开编号本质上属于实现层面的坑但在自研代码里同样要防。所以我在协商代码里强制加上点有效性检查。function flag is_on_curve(x, y, a, b, p) if isinf(x) flag true; return; end flag mod(y^2 - x^3 - a*x - b, p) 0; end演示用的曲线参数我选的是p 97a 2b 3基点G (3, 6)。可以手算验证一下6² 363³ 2×3 3 36确实在曲线上。这个参数规模非常小只适合演示协议流程真实使用必须用P-256等标准曲线。这里我特意说清楚免得有人直接把p97拿去交生产系统的差事。p 97; a 2; b 3; Gx 3; Gy 6; dA 23; dB 17; [PAx, PAy] ecc_mul(dA, Gx, Gy, a, p); [PBx, PBy] ecc_mul(dB, Gx, Gy, a, p); assert(is_on_curve(PAx, PAy, a, b, p)); assert(is_on_curve(PBx, PBy, a, b, p)); [Sx1, Sy1] ecc_mul(dA, PBx, PBy, a, p); [Sx2, Sy2] ecc_mul(dB, PAx, PAy, a, p); assert(Sx1 Sx2 Sy1 Sy2);协商出来的共享点是S后文就用Sx作为随机种子去生成希尔密码的密钥矩阵。这里还有一个值得交代的选择为什么不用整个点而只用x坐标其实坐标y也可以参与推导但x坐标已经足够作为种子并且x坐标占用长度更短便于后续转成随机种子。真要做协议级工程实现这一步会换成HKDF或SHA-256做密钥派生我这里直接用MATLAB的rng播种是为了保证代码一眼能看懂。2.3 希尔密码的矩阵数学与模逆希尔密码的加密公式是C K·P mod n其中P是m×1的明文列向量K是m×m密钥矩阵n是模数。解密时就要用K的模逆矩阵K⁻¹满足K⁻¹·K ≡ I (mod n)然后P K⁻¹·C mod n。问题在于并不是所有矩阵都能做模n逆。在线性代数里一个矩阵可逆当且仅当行列式不为0在模n环境里更强的条件是行列式det(K)与模数n互素。对于图像灰度场景像素范围0到255自然取n 256。256 2⁸所以det(K)与256互素的等价条件就是det(K)为奇数。这个条件非常容易验证我生成密钥矩阵时就是靠它做筛选。至于模逆矩阵怎么求Matlab里inv(K)算出来是浮点形式但好在伴随矩阵理论给了我们一个整数化路径K⁻¹ adj(K) / det(K)所以adj(K) det(K) · inv(K)。只要矩阵是整数矩阵adj(K)理应是整数矩阵但因为浮点运算乘出来的结果往往是像59.9999这样的数必须round一下再取模。最后把整个结果乘上det(K)在模256下的逆元就得到模256逆矩阵。function Kinv hill_inverse(K, n) d round(det(K)); if gcd(d, n) ~ 1 error(密钥矩阵行列式与模数不互素无法求模逆); end dinv mod_inv_extended(d, n); Kinv mod(round(d * inv(K)) * dinv, n); end function dinv mod_inv_extended(a, n) a mod(a, n); t 0; newt 1; r n; newr a; while newr ~ 0 q floor(r / newr); [t, newt] deal(newt, t - q * newt); [r, newr] deal(newr, r - q * newr); end if r 1 error(该数在模n下没有逆元); end if t 0 t t n; end dinv t; end这里的扩展欧几里得算法是求整数模逆的通用方法比费马小定理适用范围更广因为费马小定理要求模数必须是素数而256不是素数。实际跑的时候我最常遇到的报错就是“行列式与模数不互素”后面常见问题部分会专门讲。生成密钥矩阵时我用共享点坐标Sx做随机种子然后不断生成m×m的随机整数矩阵只要行列式为奇数就保留。m我选4也就是4×4的希尔密码。分组长度越大线性分析难度越高但计算量也会上涨4×4对于演示来说已经比较均衡。function K generate_hill_key(seed, m) rng(seed); while true K randi([0, 255], m, m); if gcd(round(det(K)), 256) 1 break; end end end2.4 图像预处理与高效分组策略图像加密前我先做两步处理一是如果读进来是彩色图就转成灰度图这样直接在一个二维矩阵上操作逻辑最简单二是在做希尔加密前把矩阵展平成列向量然后按m个元素一组切片。真正让性能拉开差距的是分组方式。直观写法是for循环每次取m个元素、做一次矩阵乘法但图像动辄几十万像素for循环非常慢。我实测下来一张512×512的灰度图用for循环分组加密要将近3秒用矩阵化一次乘法只需要0.1秒左右。技巧在于把展平后的向量reshape成m×N的二维矩阵其中N是分组数每一列恰好是一个分组。这样原来的“对每个分组单独做K·P”就变成了“对整个矩阵做K×blocks”一次矩阵乘法同时处理所有分组速度直接拉满。function [enc_img, pad] hill_encrypt(img, K) [rows, cols] size(img); m size(K, 1); vec double(img(:)); len length(vec); pad mod(m - mod(len, m), m); if pad 0 vec [vec; zeros(pad, 1)]; end blocks reshape(vec, m, []); enc_blocks mod(K * blocks, 256); enc_vec enc_blocks(:); enc_vec enc_vec(1:len); enc_img reshape(enc_vec, rows, cols); enddecrypt函数写法几乎一致唯一的区别是把K换成Kinv。这里我特别提醒一点加密和解密的padding处理必须完全一致。因为解密时同样要把向量长度补到m的整数倍然后reshape成分组矩阵最后再裁剪掉padding。如果两边padding规则不统一解密结果就会错位。还有个小细节像素矩阵读进来一般是uint8类型直接参与矩阵乘法容易溢出数值会被截断到255以内。我所有参与矩阵乘法的数据都先转成double最后再转回uint8显示。这点处理不好解密出来的图像会有大片白色噪点而且非常难排查。3. 完整MATLAB主流程与实测指标3.1 主流程脚本一次跑通把前面所有函数串起来主流程非常清爽。先读图再生成ECDH共享密钥再生成希尔密钥矩阵并求逆加密解密最后并列显示原图和解密图验证一致性。clear; clc; img imread(lena.png); if size(img, 3) 3 img rgb2gray(img); end p 97; a 2; b 3; Gx 3; Gy 6; dA 23; dB 17; [PAx, PAy] ecc_mul(dA, Gx, Gy, a, p); [PBx, PBy] ecc_mul(dB, Gx, Gy, a, p); assert(is_on_curve(PBx, PBy, a, b, p)); [Sx, ~] ecc_mul(dA, PBx, PBy, a, p); m 4; K generate_hill_key(Sx, m); Kinv hill_inverse(K, 256); [enc_img, ~] hill_encrypt(img, K); [dec_img, ~] hill_decrypt(enc_img, Kinv); figure; subplot(1,3,1); imshow(img); title(原始图像); subplot(1,3,2); imshow(uint8(enc_img)); title(加密图像); subplot(1,3,3); imshow(uint8(dec_img)); title(解密图像);解密图像张这样代码就基本没有大问题了。要注意的是因为希尔密码是线性变换加密后的矩阵数值范围可能超过255但mod运算已经把它拉回到0到255区间所以即使不转uint8在imshow里也可能显示异常我习惯统一转一下。3.2 直方图、相关系数与PSNR验证光看图像效果还不够论文里总得放几个量化指标。我常用三个灰度直方图、相邻像素相关系数、PSNR。直方图能直观看出加密后像素分布是否被均匀化相关系数可以衡量加密后像素之间的相关性有没有被打散PSNR则用来量化密文图像相对明文图像的差异程度。灰度直方图直接用histogram就能看加密后的图像直方图应该明显趋于均匀不像原图那样有明显的山峰。figure; subplot(1,2,1); histogram(img(:), 256); title(原始图像直方图); subplot(1,2,2); histogram(uint8(enc_img(:)), 256); title(加密图像直方图);相邻像素相关系数我一般抽5000对相邻像素计算。自然图像相邻像素相关性极高相关系数接近1加密后应该接近0说明像素被随机化打散。我写了一个简化版函数随机抽取水平相邻像素对。function r adjacent_corr(img) img double(img); N min(5000, numel(img) - 1); idx randperm(numel(img) - 1, N); [rows, cols] ind2sub(size(img), idx); idx2 sub2ind(size(img), rows, min(cols 1, size(img, 2))); tmp corrcoef(img(idx), img(idx2)); r tmp(1, 2); endPSNR是图像质量评估里的经典指标这里拿它看加密后的图像跟原图差多远。加密图像属于恶意破坏PSNR一般会掉到10dB以下才算及格。计算很简单MSE越小PSNR越高加密图像的PSNR低就说明它和原图差异足够大。mse_val mean((double(img(:)) - double(enc_img(:))).^2); psnr_val 10 * log10(255^2 / mse_val);信息熵也可以顺手算一个8位灰度图的理想熵上限是8加密后越接近8说明像素值分布越均匀。我用histcounts统计灰度频率再算熵。function h image_entropy(img) counts histcounts(img(:), 0:256); p counts / sum(counts); p p(p 0); h -sum(p .* log2(p)); end实际跑Lena图时我得到的结果大致是原图像素相关系数接近0.95加密后掉到0.01级别原图信息熵大概7.4加密后接近7.99PSNR大概在8dB左右。这三个数据放论文里已经足够说明加密效果了。3.3 性能实测与优化记录矩阵化处理带来的提升非常明显。我在普通笔记本上测试一张512×512灰度图用for循环逐块做希尔加密大概需要2.8秒改成reshape加一次矩阵乘法后只需要0.09秒左右速度提升30倍以上。解密几乎一样。如果你要加密的图更大比如2048×2048可以再进一步做分块处理不要让所有像素一次性参与矩阵计算。图像局部区域被单独加密后内存占用更可控同时也能避免MATLAB在超大矩阵乘法时频繁分配临时数组。还有一个更实用的技巧加密彩色图时三个通道分别处理不要一次性把所有像素拼成一个大向量通道独立性还能让你在解密时只对指定通道操作。另外生成希尔密钥矩阵时用了while循环去试行列式理论上平均两次就能试出一个奇数行列式所以这个循环不会成为性能瓶颈。但如果矩阵阶数变大比如8×8det计算会明显变慢这时可以改成先生成矩阵如果行列式不满足就重新生成概率足够高或者干脆预先存好几张满足条件的密钥矩阵备用。4. 常见问题与避坑技巧4.1 希尔密钥矩阵不可逆怎么办这个问题基本是新手必踩生成密钥矩阵时如果不做互素判断直接拿去解密hill_inverse函数会报错“行列式与模数不互素”。原因前面说过模256环境里矩阵可逆的充要条件是行列式为奇数。如果行列式为偶数则det与256有公约数2矩阵在模256下不可逆。解法就是在随机生成矩阵后立刻判断。我的generate_hill_key里已经包含了这个判断只要条件不满足就重新生成。但还有一种情况是密钥矩阵本身没问题可是你在输入时手滑改了一位比如把4改成44行列式就变了解密大概率失败。排查时先打出行列式和gcd(det,256)的值看是不是一开始就选错了密钥。4.2 解密出来全是噪声或乱码解密图是随机噪点通常不是密码算法的问题而是数据转换或padding处理的问题。我遇到过三种情况。第一种是加密时用了uint8矩阵参与乘法数值溢出截断解密当然对不回去。第二种是padding规则不统一加密补了3个零解密却只补了1个零导致分组错位。第三种是reshape时维数顺序没搞对MATLAB按列优先填充如果你心里想着按行切片出来的分组就是乱的。这类问题我给的排查建议很朴素先用一个8×8的极小图像跑通全流程哪个环节不对在调试窗口里一对比就出来了。小图能跑通再上真实图像不要一开始就拿512×512的大图试错。4.3 大图卡死或内存溢出大图容易卡死的原因主要有两个一是MATLAB默认在脚本里反复创建临时大数组又没有及时清变量二是矩阵化乘法虽然快但当图像特别大时单次大矩阵乘法本身会占用很大的连续内存。我实测2048×2048灰度图展开成向量是419万个元素reshape成4×N后并不算大但如果你用的是8×8密钥矩阵并且一次处理三通道彩色图内存占用就会明显上涨。对策很简单加密前先对图像做一次尺寸缩放预览或者分块处理。分块时每个块独立完成加密解密最后拼回去。还要记得在脚本里用clear及时释放不需要的变量特别是中间生成的blocks和enc_blocks。另外加密过程的mod运算结果依然是double矩阵如果内存还很紧张可以在分块后立刻转uint8回收内存。4.4 ECDH共享密钥不一致与安全实现细节共享密钥不一致最典型的原因是曲线参数在传给双方时出现了偏差。比如A用p97、a2、b3B不小心把a写成了3两边算出来的共享点S完全不同后面所有密钥矩阵都不一样。我自己调试时甚至遇到过因为modinv_fermat里a为负数没有先取正导致点加运算在某些输入上出错共享点坐标离奇偏离的情况。所以协商成功以后代码里加一个assert断言比较稳妥。安全实现方面补充一点前面提到的公钥有效性校验一定要留。椭圆曲线密码的很多攻击比如无效曲线攻击核心思路就是让受害者去乘一个不在原曲线上的恶意点。因为标量乘法只依赖曲线参数a和点坐标不校验的话攻击者就能把运算引导到一条阶很小的曲线上从而暴力解出私钥。还有一点是实现过程中要避免在收到公钥后做无界的大循环计算防止资源消耗型攻击。自研代码虽然不会直接暴露在公网但养成这些习惯没有坏处。最后再分享一点个人体会这套方案我前后跑了一周最折磨我的不是椭圆曲线点运算也不是希尔密码求逆而是MATLAB里double类型和模运算之间那些微妙的小问题。比如round(d * inv(K))乘出来的伴随矩阵如果不做round解密图像上就会有零星噪点肉眼看着很像是算法错了实际上只是浮点误差。这些问题只有在你把每一个环节都调试到极致之后才会意识到也恰恰是这种调试过程让你对有限域运算、矩阵可逆性、图像编码这些基础概念有了真正的体感。如果你后续想在这个项目上继续扩展我非常建议走两条路一是把方案从灰度图扩展到RGB三通道同时对三个通道分别用不同的密钥矩阵加密强度和趣味性都会提升二是把希尔密码换成AES或者加入置乱操作保留ECDH密钥协商框架这样就能把“混合加密”这一套完整思想迁移到更安全的算法上。对我来说这个项目的价值不在于它的密码强度能对标工业级标准而在于它把两门看似不相关的数学工具真正在一块图像数据上拧成了一股绳。