☰
AES-256 MATLAB 实现:密钥扩展、CBC 填充与标准向量验证
2026/10/30 3:27:44 网站建设 项目流程

简介:这份Matlab——AES_256.zip面向MATLAB编程学习者与密码学入门者,提供AES-256对称加密算法在MATLAB 2012B环境下的可运行实现,帮助读者理解256位密钥扩展、轮函数与ECB模式的基本处理流程。包内共16个文件,以13个.m函数脚本为主体,分别承担S盒字节替换、行移位、列混淆、密钥加、密钥扩展及加解密主流程等环节,另附1个.txt许可说明、1个.pdf算法参考和1个.xlsx数据表,压缩包约448KB,文件按功能拆分,便于逐模块阅读、调用与对照实验。已有532人学习下载。读者可借助核心测试脚本验证加密与解密结果,结合ECB与PKCS7填充实现观察分组处理细节,并通过正向与逆向轮函数脚本梳理轮密钥生成、状态矩阵变换和逆变换路径。对需要完成课程实验、算法复现或小型数据加密工具开发的读者,这份代码具备较直接的参考价值,也能帮助加深对分组密码结构及ECB模式局限性的认识。

1. 为什么一个 AES-256 的 MATLAB 包,值得先跑通再谈用不用

上周有同行问我:MATLAB 里跑完仿真落盘的.mat文件,几百兆一份,里面有工艺参数和实测数据,直接扔在共享盘上总觉得不踏实。他试过调外部脚本做 AES-256 加密,结果打包分发时依赖装不上,最后一圈又绕回 MATLAB。这类 zip 包的价值不在“多一个函数”,而在于整条链路——密钥扩展、轮函数、填充、分组模式——都摊开在你面前,出问题能自己定位,而不是对着一团黑匣子猜。适合手上有一批需要可审计落盘的数据、又不想在 MATLAB 工程里塞一堆外部依赖的人。下面按“骨架→拆包→参数→踩坑→验证”走一遍,跑通一遍再决定要不要投。

2. AES-256 的固定骨架:16 字节块、14 轮和 60 个字

AES 是分组密码,这决定了它在 MATLAB 里的所有接口形状:输入必须切成 16 字节一块,不足要补齐,补齐规则必须和分组模式绑定。256 位密钥对应 14 轮,128 位密钥只要 10 轮,这是很多人以为“256 只是密钥更长”的第一个误解——轮数、密钥扩展的分支逻辑、轮密钥总量全都变了。四类基本操作里,SubBytes 提供唯一的非线性,ShiftRows 和 MixColumns 负责把单字节的影响扩散到整块,AddRoundKey 是唯一真正把密钥揉进去的一步。前 13 轮四步全做,第 14 轮不做 MixColumns,这个“少一步”不是省事,而是为了保证解密结构与加密结构严格对称,去掉它整块就不可逆了。

2.1 密钥扩展:32 字节主密钥怎么摊出 60 个字

AES-256 的密钥扩展是Nk=8(8 个 32 位字)、Nb=4、Nr=14,最终得到4*(14+1)=60个字,也就是 240 字节轮密钥。扩展时按i = 8..59递推,每轮从w[i-1]派生temp,再和w[i-8]异或。真正容易写错的是分支条件:mod(i,8)==0时要做 RotWord、SubWord 并异或 Rcon;mod(i,8)==4时只做 SubWord,不旋转不加 Rcon。128 位版本只有第一个分支,照 128 的代码改 256 的人,前 8 个字对、从第 9 个字开始全错——加密照样能跑,不报错,只是结果永远对不上标准向量,这种错最难查。

function w = keyExpansion256(key) % key: 32 字节 uint8 行/列向量;w: 4x60 uint8,第 i 列是第 i-1 个 32 位字 assert(isa(key,'uint8') && numel(key)==32, 'AES-256 主密钥必须正好 32 字节'); Nk = 8; Nr = 14; Nb = 4; w = zeros(4, Nb*(Nr+1), 'uint8'); w(:,1:Nk) = reshape(key, 4, Nk); % 列优先,正好按大端拆成 8 个字 Rcon = uint8([1 2 4 8 16 32 64]); % 256 位只用到前 7 个 for i = Nk : Nb*(Nr+1)-1 temp = w(:,i); if mod(i,Nk) == 0 temp = subWord(rotWord(temp)); temp(1) = bitxor(temp(1), Rcon(i/Nk)); elseif mod(i,Nk) == 4 temp = subWord(temp); % AES-256 独有的分支,漏了就全错 end w(:,i+1) = bitxor(w(:,i-Nk+1), temp); end end function t = rotWord(t) t = t([2 3 4 1]); % 左循环一个字节 end

reshape(key,4,8)这一步把 32 字节按列排成 4×8,等价于把字节流按大端顺序切成 8 个 32 位字,w(:,1)就是key(1:4)。Rcon 数组长度只到 64,因为i/8最大取到 7(i到 56 时是最后一次mod(i,8)==0,56/8=7)。如果你把 Rcon 写成 10 个元素也能跑,但那是从 128 位版本抄来的习惯,容易让人误以为这里和轮数无关。

提示:密钥扩展的结果建议单独存一份对拍值。只验证最终密文对不对,出错时你分不清是扩展错了还是轮函数错了。

2.2 状态矩阵按列填充,MATLAB 的列优先刚好省一次转置

AES 规定 16 字节输入按列填入 4×4 状态矩阵:s[r][c] = in[r + 4c]。C 语言里二维数组按行存,所以很多实现要先转置一次;MATLAB 本身就是列优先,reshape(block,4,4)出来的s(1,1)是第 1 字节,s(2,1)是第 2 字节,天然符合规范,不需要任何转置。这个巧合省掉的不只是一行代码,还有一类常见 bug——移植别人的 C 代码时多做一次转置,加密结果会在第一轮就偏掉,而且偏得很“均匀”,看上去像 S 盒抄错了。

ShiftRows 是第 r 行左循环移r-1字节,写成circshift一行就够;MixColumns 是每列左乘固定矩阵[2 3 1 1; 1 2 3 1; 1 1 2 3; 3 1 1 2],这里的乘法不是普通乘法,是 GF(2⁸) 上的模乘,模多项式x⁸+x⁴+x³+x+1(0x11B)。整套实现里性能瓶颈和正确性风险几乎都集中在这一步的 GF 乘法上,而不是轮数。

function y = gfMul8(a,b) % GF(2^8) 乘法,模多项式 0x11B;这里统一转 double 计算,避免 uint8 溢出 a = double(a); b = double(b); y = 0; for k = 1:8 if bitand(b,1), y = bitxor(y,a); end a = bitshift(a,1); if a > 255, a = bitxor(a,283); end % 283 = 0x11B,最高位顺便被清掉 b = bitshift(b,-1); end y = uint8(y); end

a > 255这个判断的意思是:左移后如果第 9 位被置起来,就和 0x11B 异或。因为 0x11B 的第 9 位也是 1,异或之后这一位自动归零,不需要再单独bitand(a,255)。这个写法比“先掩码再判断”少一次操作,但前提是a始终在 0..255 范围内,所以函数开头必须把入参转成 double——uint8做bitshift(a,1)会直接截断高位的那个 1,后面所有判断都失效,结果是“大部分数据对,少数数据错”,属于典型的玄学现场。

2.3 加密主循环:14 轮里哪一轮不做列混淆

主循环写起来很短,但轮的边界必须卡死。第 0 轮只做一次 AddRoundKey,第 1 到 13 轮做全四步,第 14 轮跳过 MixColumns。轮密钥索引是w(:, round*4+1 : round*4+4),第 0 轮取1:4,第 14 轮取57:60,正好把 60 个字用完。

function out = encryptBlock(block, w) % block: 16 字节 uint8;w: 4x60 轮密钥;out: 16 字节密文 assert(numel(block)==16, '分组必须是 16 字节'); s = reshape(block, 4, 4); % 列优先填充,无需转置 s = bitxor(s, w(:,1:4)); % 第 0 轮:只加轮密钥 for r = 1:14 s = subBytes(s); s = shiftRows(s); if r < 14 s = mixColumns(s); % 最后一轮不做列混淆 end s = bitxor(s, w(:, r*4+1 : r*4+4)); end out = reshape(s, 16, 1); end function s = shiftRows(s) for r = 2:4 s(r,:) = circshift(s(r,:), -(r-1), 2); % 第 r 行左移 r-1 字节 end end

circshift的第三个参数2表示按第 2 维(列方向)循环,-(r-1)是左移。这个细节如果写成正数就变成右移,方向反了之后解密函数必须同步反着写,两边都反其实也能自洽——于是你的加解密闭环测试全过,一拿标准向量对拍就崩。这就是为什么第 6 章一定要有一条assert卡住,闭环自测证明不了正确性。

3. 拆开 AES_256.zip:文件角色、入口函数与最小闭环

拿到一个陌生代码包,先别急着点运行。我的习惯是解压后先dir一遍,按角色把文件归成五类:入口封装、轮函数、常量表(S 盒 / Rcon)、填充与模式、示例脚本。这个分类不需要看内容,看文件名和大小分布就能猜个八九不离十。归类之后再决定从哪个文件读起,比从上往下逐个打开效率高得多,也能提前发现“常量表是硬编码在函数里”这种影响后续优化的结构问题。

3.1 五类角色分别对应哪些函数名

下面这张表是我拆这类包时用的对照表,不同包命名习惯不一样,但角色跑不出这几类。你对着表填一遍,包的骨架就清楚了。

角色常见命名关注点
入口封装aes256Crypt、aesEncrypt参数顺序、模式字符串、返回类型
轮函数subBytes、shiftRows、mixColumns、addRoundKey输入是 4×4 还是 16×1
密钥扩展keyExpansion、expandKey是否分 128/256 两支
常量表sbox.mat、SBOX全局变量缓存方式,是否每轮重建
填充与模式pkcs7Pad、cbcEncrypt是否强制整块补齐
示例demo.m、test_aes.m是否自带标准向量

读入口函数时重点看三件事:密钥参数是 16 字节还是 32 字节、有没有模式字符串、错误输入是error还是静默返回空。第三点最关键,见过不少教学实现遇到长度不对的输入直接return,调用方拿到空数组还以为加密成功了。

3.2 最小可运行闭环:从字符串到密文再回来

确认完角色,跑一个最小闭环。这里用一个假设的入口名aes256Crypt,你按包里实际的函数名替换即可,参数顺序如果不同,把key、iv的位置对调就行。

addpath(genpath(fullfile(pwd,'aes256'))); % 把包内所有子目录加入路径 key = uint8([1:32]); % 示例密钥,生产环境别这么写 iv = uint8([1:16]); % 示例 IV,每个文件都要换 pt = uint8('MATLAB 里做 AES-256 加解密的最小闭环'); ct = aes256Crypt(pt, key, iv, 'CBC', 'Encrypt'); pt2 = aes256Crypt(ct, key, iv, 'CBC', 'Decrypt'); fprintf('密文长度 %d,还原一致:%d\n', numel(ct), isequal(pt, pt2));

这段跑通只说明加密和解密互为逆运算,说明不了算法正确。isequal(pt,pt2)为真而标准向量对不上,通常就是 ShiftRows 方向反了、或者轮密钥索引差一位这类“自洽但不合规”的问题。真正的验收在第 6 章。

如果包里入口函数只接受文件路径、不接受uint8,那就先fwrite一个临时文件再走接口,验证完再改造成内存接口。别为了省事直接改包源码,改完你就不确定问题是原来的还是你引入的了。

3.3 三种数据形态:字符串、uint8 缓冲区、.mat 文件

实际用起来,输入基本就三种形态,处理方式差别不小。

字符串要过一道编码。uint8('中文')在 MATLAB 里拿到的是字符编码的字节,不同版本、不同文件编码下结果可能不一致,稳妥做法是显式unicode2native(str,'UTF-8'),解密后native2unicode(bytes,'UTF-8')。混用这两条路,会出现“英文正常、中文乱码”的经典现象。

二进制缓冲区(比如fread读进来的图像数据)本身就是uint8行向量,直接切片即可,但要注意方向:fread默认返回列向量,而大多数轮函数按行向量切片更方便,reshape之前统一转成行向量能省掉一类维度不匹配的错误。

.mat文件要走两段式:save到临时文件,再对整个文件做字节级加密,密文另存;解密时先还原成临时.mat,再load。不建议对结构体字段逐个加密,字段名本身也会泄漏信息,而且load之后还要重建结构,得不偿失。

4. 参数怎么定:模式、填充、IV 与密钥存放

参数定错是这类实现翻车最多的地方,而且多数错不会报错,只会让安全性悄悄归零。分组模式决定相同明文块是否产生相同密文块;填充决定密文长度;IV 决定同一密钥下两次加密是否可区分;密钥存放决定前面所有工作有没有意义。这四项里,前两项影响正确性,后两项影响安全性,都得在写第一行代码之前定下来。

4.1 ECB 只配用来对拍,CBC 才是落盘默认

ECB 模式逐块独立加密,相同的 16 字节明文块必然得到相同的密文块。拿一张渐变图像试一下就明白了:加密后的数据里能直接看出原图的轮廓,因为大片同色区域的块完全重复。用于对拍标准向量没问题,用于落盘就是把数据结构暴露了一半。

CBC 把上一块密文和当前明文块异或后再加密,第一块用 IV 补位。代价是不能并行加密(解密可以并行),换来的是相同明文块在不同位置产生不同密文。对 MATLAB 这种单线程批处理场景,并行性损失基本可以忽略,默认选 CBC 没有争议。

维度ECBCBC
相同明文块密文相同,泄漏结构密文不同
加密并行可以不可以
需要 IV否是,且每次不同
单块损坏影响仅该块该块及后续一块
建议用途标准向量对拍文件与数据落盘

CBC 的“错误传播”常被当成缺点,实际上在存储场景里是优点:密文被篡改一块,解密后相邻块也会明显异常,反而容易发现。真要防篡改还得加消息认证,那是另一层的事,至少别用 ECB。

4.2 PKCS#7 填充:末块整块补齐这一条最容易漏

AES 要求输入是 16 的整数倍,PKCS#7 的规则是缺 n 字节就补 n 个值为 n 的字节。关键在边界:当明文长度正好是 16 的整数倍时,必须再补一整块 16 个0x10,不能因为“已经对齐了”就跳过。跳过之后去填充时无法区分“末尾正好是数据”和“末尾是填充”,解密端要么多截一段,要么报错。

function out = pkcs7Pad(in, blk) % in: uint8 行向量;blk: 块长,AES 固定 16 padLen = blk - mod(numel(in), blk); if padLen == 0, padLen = blk; end % 整块补齐,不能省 out = [in(:).', repmat(uint8(padLen), 1, padLen)]; end function out = pkcs7Unpad(in, blk) if mod(numel(in), blk) ~= 0 error('pad:badLength','密文长度 %d 不是 %d 的整数倍', numel(in), blk); end padLen = double(in(end)); if padLen < 1 || padLen > blk error('pad:badPad','填充值 %d 越界', padLen); end if any(in(end-padLen+1:end) ~= uint8(padLen)) error('pad:badPad','填充字节不一致,密钥或 IV 大概率不对'); end out = in(1:end-padLen); end

去填充时逐字节比对填充值,看起来多余,其实是白送的一道校验。密钥错了、IV 错了、模式选错了,绝大多数情况会在这里被拦下来,报错信息比“解出来一堆乱码”有用得多。这条检查的成本是十几次比较,收益是排错时间从半天降到五分钟。

注意:填充值越界检查(padLen < 1 || padLen > blk)必须放在取in(end-padLen+1:end)之前,否则padLen是个大数时索引直接越界,报出来的是索引错误,把真正的原因盖掉了。

4.3 IV 每次都要换,密钥别写在脚本里

IV 不是密钥,不需要保密,但必须每次加密都换,而且要用不可预测的随机源。用固定 IV 等于退化成 ECB——相同明文还是得到相同密文,只是多了个常数偏移。常见错误是把 IV 设成zeros(1,16)图省事,测试阶段看不出问题,上线后两份内容相同的文件密文完全一致,等于告诉别人“这两个文件一样”。

IV 要跟着密文一起存。常见做法是[iv, ct]拼接后整体落盘,解密时前 16 字节切出来当 IV。这样不会出现“IV 丢了导致整个文件打不开”的事故,代价是密文多 16 字节。

密钥是另一回事。写在脚本里的密钥等于没有密钥,尤其是脚本跟着项目一起进版本库的情况。退一步的做法是从环境变量或独立的权限受限文件读取,再退一步至少把密钥文件和密文分开存放、分开授权。MATLAB 里没有密钥管理服务可用时,这条底线要守住。

4.4 一张必调参数表

参数取值影响
密钥长度32 字节(256 位)决定轮数 14 与扩展分支
分组模式CBC(默认)/ECB(仅对拍)相同明文块是否产生相同密文
填充方式PKCS#7密文长度与去填充校验
IV 长度16 字节,每次随机相同密钥下密文是否可区分
IV 存放拼接在密文头部解密端能否自恢复
输入类型uint8行向量避免 double 参与位运算

这张表里的六项,前四项定错都会导致结果不对或安全性失效,后两项定错会导致运行时报错。建议把它们写成一个配置结构体,在入口函数开头做一次校验,比散落在各个函数里的assert好维护。

5. 避坑与排查:加解密对不上的五个高频原因

下面五条是我在不同项目里反复遇到的,排序按出现频率。每一条都给出可观察的现象、根因和具体改法,对着查比盲改快。

5.1 密文长度不是 16 的整数倍

现象:加密返回的密文长度是 17、33 这种非 16 倍数,解密端直接报长度错误。

原因:填充函数被调用了一次,但入口函数内部又调了一次,或者填充逻辑写成了“只在不足时补”,长度正好对齐时不补。后者在测试用例里往往碰不到,因为随手写的测试字符串长度很少正好是 16 的倍数。

解决:在加密入口加一条assert(mod(numel(ct),16)==0),把问题挡在源头。同时用长度分别为 15、16、17 的三组输入各跑一遍,这三个长度能覆盖“不足一块”“正好一块”“跨块”三种边界。填充函数只在一个地方调用,别在模式封装里再补一次。

5.2 解密出来长度对但内容是乱码

现象:numel(pt2) == numel(pt)成立,isequal为假,打印出来前几个字节正常、后面全是问号或方块。

原因:字符编码链路不一致。加密时用uint8(str),解密后用char(bytes),中间跨了 UTF-8 和本地编码两套规则。英文和数字在两套编码里字节相同,所以前半段看起来正常,一碰到中文就崩。

解决:两端都走显式编码转换,加密前unicode2native(str,'UTF-8'),解密后native2unicode(bytes,'UTF-8')。转换函数不要分散在调用方,统一封在入口函数里,调用方永远只传uint8、只收uint8。

5.3 同一明文两次加密结果不同

现象:同一份数据、同一个密钥,连续加密两次,密文完全不一样,怀疑实现有随机性 bug。

原因:CBC 模式下 IV 每次随机生成,这是设计如此。密文不同恰恰说明 IV 在正常工作。真正该警惕的是反过来——两次加密结果完全相同。

解决:不要用“密文是否稳定”来判断实现是否正确,用解密还原是否一致来判断。如果业务上确实需要确定性密文(比如按密文做去重),那是另一个需求,得用确定性加密方案,不能靠固定 IV 硬凑,固定 IV 会让 CBC 的安全性质全部失效。

5.4 GF 乘法偶发错误

现象:大部分数据加解密正常,个别文件解出来末尾几个字节不对,重跑一次结果还不一样。

原因:uint8和double混用。bitshift作用在uint8上会截断高位,a*2这种写法在uint8下直接饱和到 255 而不回绕,两种行为都不是 GF(2⁸) 想要的。这类错误不会稳定复现,取决于数据内容,属于最难查的一类。

解决:GF 相关运算内部一律转double,只在函数返回值处转回uint8。所有位运算前加一条类型断言,assert(isa(x,'uint8')),让类型错误在入口就暴露,而不是等到某个特定字节值才炸。

5.5 大文件一次性 reshape 导致内存爆掉

现象:加密几十兆的文件正常,上百兆时 MATLAB 卡死或报内存不足。

原因:一次性reshape成16×N的矩阵,中间还复制了几份临时变量。MATLAB 的写时复制在切块处理时能省不少内存,但一次性展开就享受不到了。

解决:分块读、分块加密、分块写。用fread每次读 1 MB(必须是 16 的整数倍),加密后fwrite追加到输出文件,最后一块单独处理填充。CBC 的链式状态(上一块密文)在块之间传递,所以分块不影响结果正确性。这条改完之后,内存占用从“文件大小 × 3”降到常数级。

6. 拿标准向量把实现钉死:一条 assert 顶十次目测

闭环自测通过不代表实现正确,只能说明加密和解密互为逆运算。真正的验收是拿一组公开的 256 位标准向量对拍:主密钥000102...1e1f、明文00112233445566778899aabbccddeeff、期望密文8ea2b7ca516745bfeafc49904b496089。这三行数我贴在每个项目的测试脚本第一行,跑不过就不往下做。

key = uint8(sscanf('000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f','%2x').'); pt = uint8(sscanf('00112233445566778899aabbccddeeff','%2x').'); ct = uint8(sscanf('8ea2b7ca516745bfeafc49904b496089','%2x').'); w = keyExpansion256(key); assert(isequal(encryptBlock(pt,w), ct), '单块向量对不上,先查密钥扩展'); assert(isequal(decryptBlock(ct,w), pt), '解密向量对不上,先查 ShiftRows 方向');

sscanf的%2x按两位十六进制解析,返回列向量,转置成行向量后和轮函数的入参方向对齐。两条assert分别卡加密和解密,哪条先炸就查哪边,比只卡最终结果省一半排查时间。

性能上,profile on跑一遍十兆数据,热点几乎必然落在gfMul8。单块 14 轮、每轮 16 次乘法的朴素循环,在 MATLAB 里大概几毫秒一块,够用但不快。真要提高,把 MixColumns 的固定矩阵展开成四个查表的异或组合,或者对块做向量化批处理,比换语言省事得多。不过我的经验是:除非单次加密超过一分钟,否则别优化——这份代码的瓶颈通常在磁盘 IO 和填充逻辑上,不在轮函数。

最后留一句自己的教训:我最早做这类实现时,图省事跳过了标准向量对拍,靠双向闭环验证通过就上线了,结果两个月后一次跨版本迁移才发现密文和别的系统根本对不上——方向反了,但自己和自己始终自洽。从那以后,任何加密实现的第一个提交里必须带一条assert,宁可多写十行测试,不给自己留后悔药。希望帮到你。

本文还有配套的精品资源,点击获取

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

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

立即咨询