书到用时方恨少,事非经过不知难。
本篇针对的是玲珑的第一个独立密码学/C++项目——AES-Integrals。
意在详解利用Modern
C++相较传统C/C++对此密码学攻击实现中,代码质量(可读性、维护性与拓展性)及运行效率方面的优化。
有关AES积分攻击的密码学分析,可移步AES积分攻击:霜天
aes.h
aes.h
中实现了对称密码学有关的基本函数和配套类、具备多种功能的AES类等,是被复用/拓展的根基。
struct byte
:迷雾乱花
struct byte
实现的部分节选如下:
1 |
|
略去者均为运算符重载等“重复性”内容。
struct byte
的几个特点在于:
- 该
struct
是 trivial 的,即其只有一个公有数据成员unsigned char value
,且其遵循基本类型的复制、移动及析构逻辑,因而该struct
在程序中的额外开销相较unsigned char
或其它enum
类型几乎为零;
- 其构造函数和转换构造函数采用了单向
explicit
的设计:由于认为struct byte
是一个有限“域”,故其它的整数类型(无论初始值在不在unsigned char
的范围内)向byte
的转换都应当被显式声明;反之则不然(即允许struct byte
向普通的整数类型隐式转换)。
- 仅重载了位运算符,所有运算符重载的运算对象的返回值都是
byte
,但移位运算符除外(第二操作数选择了unsigned char
)。
1 |
|
block_fromhex
:万象霜天
简介
本函数意在实现std::string_view
(泛“字符串”)向std::array<byte,16>
,即所谓“块”的转换。
定义及对比
1 | constexpr std::expected<block,std::string> block_fromhex(std::string_view sv) |
相比之下,一个传统C++的实现可能会形如:
1 | byte[16] block_fromhex(const char* ch,size_t len) |
逐行分析改进点:
- 函数原型声明上,由于
block_fromhex
默认都会开辟新的栈内存空间来存放“转换”的结果所谓csharp中的,故显然将结果放在返回值中“传出”是更好的选择。但又由于需要将异常信息传出,则只能依靠out Block result
throw
来解决此问题——潜在的栈回溯的副作用之一,即为极大限制了代码效率上限(编译器需为这个可能的“出口”进行额外分支判断等),起步即逊于能被声明为noexcept
的现代C++实现。
若选择C语言类似的解法,依靠int
作为返回值并将目标数组以 out 参数指针置于其中,且不论内存安全性,在调用代码中必须手动检查其返回值——极大增加疏漏风险的同时,使得调用逻辑变得更加繁琐。
相较之下,本实现(C++23版本)使用了std::expected<block,std::string>
,在提高内存和执行效率的同时,调用逻辑也显得更为清晰甚至没算上链式调用:
1
2
3
4
5
6
7
8
9if(auto bk = block_fromhex(buf);bk)
{
val[i] = *bk;
}
else
{
std::println("{}",bk.error());
exit(1);
} - 内部实现直接封装对C++标准库中
<charconv>
的std::from_chars
的调用,保证正确性的同时(参考C++ Core Guidelines之尽量避免“手搓”函数,优先级:标准库>三方库>手搓)。from_chars
的返回值直接用 Structural Binding 捕获,通过看ptr
是否为其起始位置+2即可快速判断解析是否正确。
- 循环用
auto&
来遍历res
(std::array<byte,16>
),保证每行只解析前16组有效的十六进制字符,std::string_view
在提供对C样式字符串、std::string
等较好的适配性的同时,性能比另开std::string
/const std::string&
高且保证了内存安全所谓view-type的魅力。
- “移进”扫描点时采取了跳过所有非空格字符的策略,此处封装了对
std::isblank
的调用(因原函数返回值是int
,似乎可以用std::invoke_r
更进一步改善?)
该函数的进一步封装和拓展点应在“流”的phase,及对部分异常处理的差分策略。
gmul
创新的定义
1 | namespace _gmul |
解析
gmul
( \(GF(2^8)\)
下的乘法)的实现是AES加密的重要组成部分。既有C/C++对这一模块的实现基本分三种思路:
- 只实现
gmul
的计算逻辑(如上述gmul_fn
所述),所有数值全部在运行时计算;
- 利用AES类中乘法的第二操作数只有8个选项(2,3,1,1及其“逆元”)的特性,将待获取的乘法结果(256*8种组合)“打表”备用;具体实现或为静态字面量悉数枚举(即常量初始化之),或为程序运行开始时完成计算并置于静态存储区种。
这几种实现思路各有优劣,综合来看第三种实现较好;但现代C++引入的constexpr
、consteval
和对编译期计算的全面支持,使另一种可能性浮出水面——将计算逻辑使用编译期函数表述之,并在编译期完成全部计算(形如上述的consteval table_t table_gen();
,consteval
约束其解析一定在编译期完成)。
另外,gmul
这个外部调用“接口”的函数采用了 ranges
algorithm “同款”的函数对象实现。
此处“函数”
gmul
的实现照顾了待在AES
类中调用所需的乘数,使其调用逻辑更为简洁。
AES
类
主要实现了AES类的加/解密公开接口及内部组件的私有函数。节选形如:
1 | using block = std::array<byte, 16>; |
解析
AES类通过size_t
类型的模板参数提供了对不同轮数加/解密的支持(默认值为10轮),同时通过内联一个concept
约束其轮数不小于1。
由于轮密钥存储等环节中利用了大量
Rounds
有关的参数,从编译器优化、运行效率、潜在应用场景等角度出发,笔者没有将轮数作为运行时参数。
encrypt
等函数需要进行“遍历”时,笔者在其中大量应用了std::views::iota
及其封装变形(iota_word
/iota_byte
)。
其相较传统for
循环的优势有:
- 调用语义、逻辑简洁统一(“一目了然”的
iota_word
);
std::views::iota
的循环是安全的(其类似不能被“打断”的迭代器,参考Python中range(x)
的行为),从根源防止了循环体内临时修改变量等操作导致的bug;
- 由于该循环确定(必定执行16次),因而编译器可以有更大的优化空间(
const
变量和各类编译器常量的应用同理)。
std::formatter
相关
1 | template<> |
目前(截至这篇Blog)本aes.h
实现中没有提供自定义的窗口,仅使用format
函数的部分“快速”复用来提供byte
和block
及类似类的格式化。
有趣的是ADL(Argument-Dependent Lookup)在
byte
的定义中“发威”了一番:由于全特化(Full Specialization)时使用了std::formatter
声明之,此时填写其模板参数时会优先在namespace std
(而非默认的全局空间)中查找byte
,因而必须明确::byte
方能正确特化之。
小趣事2.0:std::byte
似乎在标准库中还没有std::formatter
特化,连个unsigned char
的forwarding都懒得干