下载一个安装包,网站会给你一串 SHA-256;Git 里每个 commit 也是一串哈希;密码更是绝不能明文存放。这些场景看起来八竿子打不着,干的却是同一件事:把任意长短的数据,收成一枚固定长度的指纹。
一封邮件几个字节,一部电影几个 GB,拿到 SHA-256 手里,出来都是 64 个十六进制字符。不是「长文件多给几位、短文件少给几位」,而是门口宽度锁死了。
这篇文章就回答一件事:哈希到底怎么算,以及不同长度的东西,究竟是怎么被压成同一个长度的。
这篇文章记录的是让AI帮自己理解哈希的过程,供自己查阅。如有错误,欢迎指正!
一、哈希不是加密,是指纹
先把最容易混的概念拆开。
加密的目标是「藏起来,以后还能打开」。哈希的目标是「留下指纹,以后拿来对一下」。
一枚靠谱的哈希,通常有四条脾气:
- 任意输入,固定输出。 一句话、一本书、一个文件,出口宽度一样。
- 同样的原文,永远得到同一枚指纹。 换一台电脑、换一天算,结果不变。这叫确定性。
- 原文改 1 个比特,指纹就变得面目全非。 这叫雪崩。别指望「只改了一个标点,哈希也差不多」。
- 几乎不能反推。 你拿着指纹,找不到一条现实可行的路,把原文还原回来。
所以哈希不是加密。加密必须能解密;哈希算完就回不去。它更像文件的指纹、数据的校验码,而不是一把能开锁的钥匙。
二、先用手算一遍,看清「计算」到底在干什么
SHA-256 内部有 64 轮搅拌,不适合从头抄到尾。但哈希的骨架其实就三步,用一个能手算的玩具版本就看得清:
- 把每个字符变成数字(ASCII)
- 从左到右反复混合:
h = h * 31 + 字节 - 最后对 101 取余,把结果压进 0 到 100
以 "cat" 为例:c = 99,a = 97,t = 116。
- 读入
c:h = 99 - 读入
a:h = 99 × 31 + 97 = 3166 - 读入
t:h = 3166 × 31 + 116 = 98262 - 压短:
98262 % 101 = 90
所以这个玩具哈希里,hash("cat") = 90。
只改第一个字母,变成 "bat"(b = 98):
98 × 31 + 97 = 31353135 × 31 + 116 = 9730197301 % 101 = 38
一个字母之差,90 变成 38。这就是雪崩的迷你版。
这一段里真正发生的事情只有三件:
字节变数字 → 反复混合 → 压进固定范围。
工业级哈希同构,只是混合手段狠得多。你也可以先记住一个更生活的类比:哈希内部是一只固定大小的杯子。 往里倒一勺糖还是倒一整壶果汁,杯子容积不变。出口为什么永远那么长,第一层答案就在这里——结果根本不是「原文的缩写版」,而是「这只杯子最后装成了什么样」。
三、重点来了:任意长度,如何变成统一长度
杯子的比喻还太粗。真正的算法把「压成定长」拆成三层,一层比一层硬。
第 1 层:内部始终只有一张固定大小的工作台
哈希不会把原文拼进结果里。它内部始终维护一份宽度锁死的状态。SHA-256 的状态是 256 位,最后拿出来当指纹的,就是这份状态。
数据再长,也只是一块块倒上这张台子搅拌。台子多大,指纹就多长。
用一个弱到能手算的例子看「长度是怎么死的」。约定:状态只有 1 个字节,初值 0,来一个字节就和当前状态做异或。
"hi" 两个字节:h = 104(二进制 01101000),i = 105(01101001)。
01101000
XOR 01101001
= 00000001
结果是 1。一整本书也可以继续异或下去,出口照样是 1 个字节。
这个算法很烂,碰撞随手就能造。但它把一件事做绝了:输入长度已经不能决定输出长度。 定长这件事,在「固定大小的状态」这一层就已经成立。
第 2 层:切块,再用压缩函数一块块压短
真算法不会把整份文件一次塞进搅拌机。内存吃不消,也不好设计。常见结构是:
- 先把原文填充,让总长变成「块大小」的整数倍。SHA-256 一块是 512 位(64 字节)。
- 切成块:
B1, B2, …, Bn - 准备一个和输入无关的固定初值
H0。空文件也有指纹,就是因为H0事先就在。 - 每一块都过同一个函数:
H_i = Compress(H_{i-1}, B_i) - 最后的
H_n就是指纹,长度永远等于H0
这个「旧状态 + 新数据块 → 新状态」的循环,有个名字叫 Merkle-Damgård。名字不重要,形状重要:
H0(固定初值,256位)──┐
├─ Compress ──► H1(仍是256位)──┐
B1(本块,512位)──────┘ │
├─ Compress ──► H2(仍是256位)
B2 ───────────────────────────────────────────────────┘
│
...
H(n-1) ──┐
├─ Compress ──► Hn(指纹,永远256位)
Bn ──────┘
真正把东西「压短」的,是每一步的 Compress。以 SHA-256 为例,它吃进去的是:
- 旧状态 256 位
- 本块数据 512 位
一共 768 位。吐出来的只有 256 位新状态。768 进、256 出,这才是压缩函数的本义。
文件越长,块数 n 越大,Compress 就要多调用几次。但每一次出口都是 256 位。所以:
1 个字节和 1 个 GB,出口一样宽——不是因为长文件被「概括」成了短文件,而是因为工作台的宽度从来没变过。
第 3 层:为什么必须先填充,还要把原文长度写进去
切块有个前提:进来的必须是整块。3 个字节的 "abc" 也得先被拉成 64 字节,否则最后那一截没法送进压缩函数。
SHA-256 的填充规则很具体,可以手写出来:
- 先补一个
1位。落到字节上就是0x80 - 再补若干个
0,让「原文 + 填充」去掉最后 8 字节之后,长度正好是 56 字节(也就是 448 位) - 最后 8 字节写下原文的比特长度
"abc" 三个字节,比特长度是 24,十六进制是 18。填充后的唯一一块是 64 字节:
61 62 63 80
00 00 00 00 00 00 00 00
00 00 00 00 00 00 00 00
00 00 00 00 00 00 00 00
00 00 00 00 00 00 00 00
00 00 00 00 00 00 00 00
00 00 00 00 00 00 00 00
00 00 00 00 00 00 00 18
前三个字节是 a b c,第四个是 80,中间 52 个零,末尾记下「原来只有 24 比特」。短消息就这样被拉齐成一整块,压缩函数只需跑一次。
长文件则是另一幅画面:前面很多完整的 64 字节块依次搅拌,最后不够一整块的尾巴再按同样规则补齐。循环次数变了,杯子容积没变。
那为什么还要把长度钉在最后?因为填充必须保证:不同的原文,填完之后不能变成同一串比特。 把「原来有多长」写进最后 64 位,是最硬的一种保险——3 字节和 4 字节,哪怕内容看起来差不多,末尾那格长度也对不上。
到这里可以把定长机制收成一句:
填充负责切齐,压缩函数负责压短,固定宽度的状态负责把出口锁死。
四、SHA-256 每一块内部怎么搅拌
定长问题解决了,还剩「这一块倒进杯子之后,怎么拌匀」。SHA-256 对每一块大致做三件事。
先扩字。 64 字节拆成 16 个 32 位字,再用位移和异或扩成 64 个字。后面每一轮都要用其中一个。
再跑 64 轮。 内部有 8 个 32 位工作变量 a, b, c, d, e, f, g, h,一开始放固定初值。每一轮会:
- 用一部分变量做「按位选择」
- 用另一部分做「少数服从多数」
- 左右旋转若干位
- 加上本轮常量和本轮的消息字
- 加法都是模 (2^{32}),超出 32 位就截断
- 结果灌回这 8 个变量,旧值依次挪位
最后加回去。 64 轮结束,把 a…h 加回当前摘要,得到下一块要用的 H_i。所有块走完,8 个变量拼起来,就是 256 位指纹,写成十六进制便是 64 个字符。
一轮可以在纸上跟踪,64 轮没有手算的必要。电脑做的就是把加、旋转、异或重复足够多次,让每一个输入比特都有机会把整张工作台翻个底朝天。
这里有个很容易反着记的分工:
64 轮负责拌匀——雪崩、难反推、改一个字指纹就变。定长不靠轮数,靠工作台有多宽、压缩函数吐出多少位。
标准测试向量可以用来对答案。"abc" 的 SHA-256 是:
ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad
只把最后一个字母改成 "abd",会得到完全另一串:
a52d159f262b2c6ddb724a61840befc36eb30c88877a4030b65cbe86298449c9
两个字符串都是 3 字节,指纹却没有一位能对上的「差不多」。这就是雪崩。它们的长度之所以相同,则仍然只因为出口是那张 256 位的工作台。
五、为什么几乎不能反推,以及几个常见误区
玩具哈希模 101,出口只有 101 种可能。不同字符串撞到同一个余数,太正常了,这叫碰撞。
SHA-256 的出口有 (2^{256}) 种可能。这个数字大到:把全世界的计算机加起来穷举,也摸不到边。所以在工程上,你既不能从指纹反推原文(原像),也找不到另一份不同文件碰巧得到同一枚指纹(碰撞)。
但理论上碰撞一定存在——入口是任意长度,出口只有 256 位,鸽巢原理摆在那儿。好哈希不承诺「没有碰撞」,只承诺「找不到碰撞」。
顺便清掉三个坑:
- 哈希不是加密。 看不到原文,不是因为「锁上了」,是因为搅拌不可逆。
- 登录场景不要直接拿 SHA-256 存密码。 那要用专门的密码哈希,并且加盐,比如 Argon2。普通文件指纹和「防暴力猜密码」不是同一个工具。
- 不要和不少语言里的内置
hash()搞混。 那个常常是给哈希表、字典用的,进程一重启结果就可能变,不能当校验码。文件指纹、Git、网盘校验,用的是 SHA-256 这类密码学哈希。
六、自己跑一下
最短的实验是看长度,不是看那串字符好不好看:
import hashlib
print(len(hashlib.sha256(b"a").hexdigest()))
print(len(hashlib.sha256(b"a" * 10**6).hexdigest()))
print(hashlib.sha256(b"abc").hexdigest())
前两行都会打印 64:1 个字节和 100 万个字节,指纹一样长。第三行则应是:
ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad
对得上,说明你机器上的 SHA-256 和全世界用的是同一套规则。
收束
哈希可以记成一台固定大小的搅拌机。
原文先进去被填充切齐,再被切成同样宽的块;每一块都经压缩函数拌进同一只杯子。杯子是 256 位,指纹就是 256 位。文件再长,只是多搅拌几轮,不会把杯子加大。
所以你会看到那个有点反直觉的事实:一句话就能改,指纹却永远那么长。 改的是杯子里的味道,不是杯子的尺寸。