三思而后行。
三思(SanSi)是一个新型迭代哈希密码,可以接收输入的文件或字符串并将其哈希为 80
位的位串。
不同于传统顺序处理的 Sponge 结构,SanSi 在其基础上引入了更多的复杂性:将顺次线性处理分块的结构改变为树形结构,通过在处理顺序的维度引入更多的复杂性(在每次读入时不直接顺序处理而是在积累了一定数量后以树形结构的方式进行处理,即“三思而后行”)加强了密码,使得长度扩展攻击等攻击方法更加困难。同时,三思还在 f
函数处引入了不同的方法。
本算法由刘子君、刘泽禹、庞元喆、徐明启、刘方舟设计、实现并完成评估。
组员 | 分工 |
---|---|
刘子君 | 文献查阅,生日攻击检测,算法随机性检测,文档撰写 |
刘泽禹 | 文献查阅,算法设计,算法实现,效率评估,文档撰写 |
庞元喆 | 文献查阅,算法设计,算法实现 |
徐明启 | 文献查阅,算法审查 |
刘方舟(组长) | 算法实现,随机路径检测,文档撰写 |
如果要运行 Demo
,可以在文件夹下 make all
并运行下列几种命令:
- 文件哈希:
./main <arg1>
。其中<arg1>
为一个文件的路径,其哈希值将被打印在标准输出中。 - 字符串哈希:
./main
进入交互模式,在控制台中输入一个字符串,输出该字符串对应的哈希值。./main <arg1> <arg2>
。其中<arg1>
与<arg2>
分别为输入、输出文件地址。输入文件中以换行符分割若干组需要哈希的字符串,输出文件的每行为对应字符串的哈希值。
可以通过在自定义程序中引用 SanSi 算法头文件、并在编译时进行链接进行使用,具体示例如下。
#include "sansi.h"
#include <iostream>
#include <cstdio>
#include <cstring>
using namespace std;
int main() {
Sansi crypto; // 实例化 SanSi 哈希计算器
// 情形一: 字符串哈希
string data = "目标字符串"; // 或者: const char *data = "目标字符串";
cout << crypto.stringHash(data) << endl; // 输出哈希结果
...
// 情形二: 文件哈希
const char *file = "目标文件路径";
cout << crypto.fileHash(file) << endl; // 输出哈希结果
...
// 情形三: 自定义流式运算
uint64_t *src;
int length;
...
crypto.add_block(src); // 每调用一次 add_block(), 可添加一个 1024 位的分块
...
// 当确定输入即将结束时,可以对输入做填充
length = Sansi::pad(src, length); // 参数 length 为当前输入 src 的[字节]数,返回填充后的[字节]数
for (int i = 0; i < (length >> 3); i += 16) {
crypto.add_block(src + i); // 将余下的分组添入 crypto
}
cout << crypto.hash() << endl; // 输出哈希结果
crypto.reset(); // 若之后继续使用该对象,需清空其状态
...
return 0;
}
在哈希函数密码一类问题中,哈希函数
在这样的背景下,我们考虑在处理顺序的维度上引入更多的复杂性,通过树形结构而非线性结构处理数据。此时,当我们通过黑盒的视角来观察哈希函数
在这一想法的基础上,我们的工作集中于:
- 设计一个合理的计算顺序规则,使得对于不同的字符串输入
$h$ 能够将其以较为混乱的顺序进行处理,并且保证同一个字符串的处理顺序是一定的。 - 将字符串分块的位置信息内嵌入
f
函数中,使得攻击者无法通过仅仅调换两个分块的顺序找到碰撞。
我们算法设计的最终结果采取了等待输入积累了一定组数的分组后再进行树形合并处理的方式。在我们看来,这一过程就好比“三思而后行”的过程(积累、再执行),与传统迭代结构中“直接行动”的范式相区别。因此,我们将我们的设计理念融入算法的名称当中,命名其为“三思”。
算法的基本流程如下图。
Sponge 结构的函数主要是由内存状态
与采用 Sponge 结构的 SHA-3 类似,SanSi 采用了大小为 1600
比特的内存状态,并将其组织成为一个
在 64
位的整数
为方便起见,我们约定沿
与传统的线性处理结构不同,SanSi 引入了树形处理结构。约定 1024
比特为一个分组,8
个分组为一个大组。
对于每个分组输入,算法首先根据组的奇偶性进行判别:奇数位的组将与全0的内存状态相异或;偶数位的组将与全1的内存状态相异或(异或范围为 1024
比特)。异或后运行一次 f
函数进行打乱。
对于每一个输入的块,我们通过其位置和内在信息计算出一个对应的权值作为启发值
在完成了每个分组的状态内嵌后,我们的算法得到了一系列的内存状态 8
个分组为一个大组,每当读入一个完整的大组,依据启发值从大到小将当前的所有内存状态依次进行合并。对于内存状态
-
更新位置信息: $$ \text{pos}^* = \lfloor \frac{\text{pos}_1 + \text{pos}_2}{2} \rfloor $$
-
更新内存状态: $$ S^* = S_1 \oplus S_2 $$
-
进行打乱: $$ S^* = f(S^*) $$
-
重新计算启发值
$k$ 。
最终的合并顺序可视化后呈现树形结构。一种可能的合并示意图如下图所示。
与 Sponge 结构类似,本算法采用了迭代输出的方式,在共计 20
轮中每轮输出四位 f
函数。
在“设计理念”一节中,我们已经提到,由于树形结构与线性结构的不同,本算法的 f
函数还需要嵌入位置信息以避免攻击者通过简单调换两个分块的位置来制造碰撞。在这一节中,我们会通过介绍 f
函数的具体流程来说明这一目的是如何完成的。
具体而言,本算法的 f
函数中包含两轮,每一轮依次完成
在这一阶段中,我们将 64
位拆分为 4
个 16
位的整数,不妨设为
对于任一 8
位整数,我们首先考虑对其进行 7 点模板计算(stencil),即
$$
t_{i, j, k} = g(t_{i - 1, j, k}, t_{i, j - 1, k}, t_{i, j, k - 1}, t_{i, j, k}, t_{i, j, k + 1}, t_{i, j + 1, k}, t_{i + 1, j, k})
$$
模板计算的内容与CRC校验和的思想类似,即将等式右侧的7个数值相加得到整数
while z > l:
z <- z % l + z / l
为了计算的效率,我们将上述循环简化为固定进行三轮,再对
z <- z % l + z / l
z <- z % l + z / l
z <- z % l + z / l
z <- z % l
设这一过程为函数
同时,在
64
位进行位移轮换。在 SHA-3 算法
64 位、65128 位,并以低位在前(小端)格式存储。
为了支持任意长度的输入,有必要对输入进行填充。SanSi 规定任何输入均需经过填充,并选用
- 在输入的末尾加入两个比特
$11$ 。 - 在上一步加入的两个
$1$ 之间填入 0~1023 个$0$ ,使得输入的长度为分组长度1024
的整数倍。
由于测试目标是算法实现效率,因此在测试时除去了 IO
时间与生成数据的时间,并且预先进行了两组数据的插入作为预热。
在具体测试框架中,代码使用 SanSi 加密了一个长度为 10000000 * 128
字节的字符串,即处理了 10000000
个分块共 10000000 * 1024
位的数据,并结合运行时间计算出运行效率,具体代码见 performance_test.cpp
。
上述流程可在 make all
后运行 ./performance_test
完成。
- 设备:ThinkPad X1 Carbon
- 处理器:Intel Core i7-10710U CPU
- 系统:Windows 11
- WSL 2:Ubuntu 20.04
测试结果较为稳定,且程序运行效率约为 436.619 Mbps
,在要求的 200 Mbps
以上。
使用 NIST 统计学工具包 完成随机性检测。
通过随机改动字节串中随机选取的部分比特,生成相近的多组明文,经过哈希后,获得待检测文件;之后使用 NIST 工具包对文件进行随机性分析。
上述流程可通过命令:
bash test.sh
完成。共生成 128000
组,长度为 256
个字符的随机相近明文,经过 SanSi 算法哈希并转换为 ASCII 编码的 01 串。
将待检测文件分为 10
个比特流,对其进行 NIST 工具包的所有随机性检测项目,结果显示,哈希结果通过了所有的随机性检测项目,如下表所示(具体检测结果文件详见 /result/finalAnalysisReport_test1.txt
):
项目 | P Value | 通过率 |
---|---|---|
Frequency(频率检验) | 0.534146 | 1/1 |
BlockFrequency(块内频数检验) | 0.911413 | 1/1 |
CumulativeSums(累加和检验) | 0.350485/0.534146 | 2/2 |
Rank(二元矩阵秩检验) | 0.534146 | 1/1 |
FFT(离散傅里叶变换检验) | 0.911413 | 1/1 |
Runs(游程检验) | 0.350485 | 1/1 |
LongestRun(块内最长游程检验) | 0.739918 | 1/1 |
NonOverlappingTemplate(非重叠模块匹配检验) | - | 148/148 |
OverlappingTemplate(重叠模块匹配检验) | 0.739918 | 1/1 |
Universal(Maurer 的通用统计检验) | 0.534146 | 1/1 |
ApproximateEntropy(近似熵检验) | 0.534146 | 1/1 |
RandomExcursions(随机游动检验) | - | 8/8 |
RandomExcursionsVariant(随机游动状态频数检验) | - | 18/18 |
Serial(序列检验) | 0.739918/0.739918 | 2/2 |
LinearComplexity(线性复杂度检验) | 0.350485 | 1/1 |
我们对算法进行生日攻击,具体方法为:连续地生成长度为 128 字节的随机字符串,使用 SanSi 计算其哈希值,存储“哈希值-原串”对,直至找到碰撞或者达到最大攻击次数。攻击代码见 birthday_attack.cpp
。
由于硬件设施和算力限制,我们最多仅进行到
我们利用 Floyd's Cycle Detection 算法对 SanSi 寻找碰撞,攻击代码见 floyd_cycle.cpp
。
Floyd's Cycle Detection 的实现方法为:
- 阶段一:生成一个随机字符串作为初始值,并赋给两个字符串,分别称为“快指针”和“慢指针”。在每一轮迭代中,“快指针”对自己进行两次哈希,并将自己更新为该哈希值;“慢指针”对自己进行一次哈希,并将自己更新为该哈希值。不断迭代,直至“慢指针”与“快指针”相等(找到碰撞)或者达到最大攻击次数。
- 阶段二:若阶段一找到碰撞,则进入本阶段。将“慢指针”复原到初始值,“快指针”不动。在每一轮迭代中,“慢指针”和“快指针”均对自己进行一次哈希,并将自己更新为该哈希值。不断迭代,直至两个字符串相等。需要注意的是,此阶段需要另外两个字符串保存“慢指针”和“快指针”对自己进行哈希前的原值,当迭代结束时,这两个字符串就具有相同的哈希值,于是我们就找到了一组碰撞。
由于硬件设施和算力限制,我们最多仅进行到
- 算法的结构参考了 SHA-3 的结构。
- 算法的软件实现参考了 hash-library 仓库的实现方式。
- 算法评估的随机性检测使用了 NIST 统计学工具包。