SHA256算法全景(图)

1,491 阅读9分钟

SHA256算法,是主流的摘要计算算法。本文试图使用一个全景的视角,帮助读者了解这个算法的全貌,从而理解其基本工作原理、流程和相关的实现细节

全景图

SHA256计算算法和操作的全景如下:

SHA256View.png

编码和补位

SHA256,包括所有基于数据块操作的算法,在处理之前,都首先需要将原始数据进行编码(一般将信息转换为比特流的形式),然后分割成固定大小的数据块,这样一般都还会进行补位操作。SHA256使用512位的数据块,用16进制表达,就是64个字节。

编码操作比较简单,如果认为输入的数据是UTF8编码,可以使用TextEncoder来进行编码,编码后得到一个字节数字,要将其补到能够被64位整除。SHA256的补位规则如下:

  • 补位后的字节数,能被64整除
  • 补位后,最后8个字节,表示数据的原始长度(位长),这段补位笔者称为尾码
  • 补足的位置,必须以0x80(一个字节)开头,其余为0,这段补位笔者称为补码

根据上述规则,我们提出一个补码为0的长度的计算公司(以字节数为单位)

补码长度n = 63 - (原文长度l + 8) % 64

最后补足的字节数组长度为(l+1+n+8),内容为:

[...原文, 0x80, n个0, ..尾码]

例如字符串abc,编码和规则内容如下:

项目内容
原文abc
编码[0x61,0x62,0x63]
字节长度3
位长度3x8=24(位长)
尾码00000018
余数(3 + 8) % 64 = 11
补码63-11 = 52 (个0,"0x00")
全码61626380 52x"00" 00000000 00000018

这里还可以看到一个限制,就是原文的长度不能超过2^64,否则会溢出无法表达,当然这个数值也应该足够使用了。

核心操作和代码解析

SHA256的计算,大体分为三个部分,初始数据(Init),扩展阶段(Expansion)和压缩阶段(Compress)。

在初始化阶段,需要准备两个数组,K和I。K是一个长度为64的整数数组,分别为前64个素数的立方根的小数部分的前32位(二进制),如果用16进制表示就是长度为8的hex字符串。K在每个压缩阶段都会使用。 I是整个摘要计算的初始输入数组,长度为8,I的定义是前8个素数的平方根的小数部分的前32位(二进制)。I只会在计算开始的时候用到一次,后续压缩阶段计算的输入都是前一轮计算的结果,直到整个摘要计算过程完成,最后会生成一个长度为8的整数数组,通常会再将它们分别转换为长度为8的hex字符串,连接后得到一个长度为64的hex字符串作为摘要的结果。一般在工程上,为了提高性能,I和K都直接使用预设常量数组。

对于扩展阶段,笔者的理解,其目的是为了迭代数据块,使用当前块信息,生成工作参数数组W,结合常数数组K,进入压缩阶段,再进一步迭代计算64次,然后再进入下一个数据块...,直到所有迭代计算完成。所以,扩展阶段的本质是给压缩阶段准备参数W,这个参数数组长度为64,其中前16个是由当前块数据(64个字节)再次编码(4个一组)得到,其余由W数组中前面的元素,根据公式(σ0,σ1)计算得到。

在压缩阶段,输入的数据是一个长度为8的数组(由初始数据或上轮计算结果得到),可以通过变换(如图)生成一个映射数组,对于每个扩展阶段,压缩都会执行64次(消耗W和K)。压缩阶段使用的公式包括(Ch,Maj,Σ0,Σ1)等。压缩阶段完成后,可以得到一个长度为8的整数数组,再将这个数组和本轮输入数组进行对位相加,得到的新数组,进入下一轮压缩。这些过程,直到所有扩展和压缩计算完成后,最后得到的结果数组,就是摘要计算的结果,它是8个整数,可以根据需要转换为字节数组(32byte),或者Hex字符串(64个字符)。

在后面这两个阶段,其实只有6个核心变换操作,和一个位操作(ROTR,循环右移,JS语言原生没有这个操作,使用函数实现),它们的定义和代码如下:


    ROTR : (n, x)    =>(x >>> n) | (x << (32-n)),      // Rotates right (circular right shift) 
    Ch   : (x, y, z) =>(x & y) ^ (~x & z),             // 'choice'
    Maj  : (x, y, z) =>(x & y) ^ (x & z) ^ (y & z),    // 'majority'
    Σ0: (x) => C.ROTR(2, x) ^ C.ROTR(13, x) ^ C.ROTR(22, x), // other logic functions
    Σ1: (x) => C.ROTR(6, x) ^ C.ROTR(11, x) ^ C.ROTR(25, x),
    σ0: (x) => C.ROTR(7, x) ^ C.ROTR(18, x) ^ (x >>> 3),  
    σ1: (x) => C.ROTR(17,x) ^ C.ROTR(19, x) ^ (x >>> 10),

这些流程和函数都准备好之后,代码的实现和组装都是非常简单和清晰的,详见后续的代码实现。

HMAC

HMAC(Hash Massage Auth Code,摘要消息验证码),可以简单的理解成加密的摘要算法。摘要计算的算法和规则是公开的,对于特定内容它的计算结果也是固定的,在某些场景下不能满足业务的需求,于是人们就在Hash的基础上,开发了MAC算法,可以使用一个密钥来参与变换,来生成一个动态的,只能由密钥持有者验证的摘要信息。

HMAC是在Hash的基础上进行的,所有HMAC也有不同的家族和设置,对应使用的摘要算法。HMAC56,对应使用SHA256。它的设定是: 位长度b=64byte,内部摘要长度L=32byte,还使用两个计算常量0x36(用于内部摘要)和0x5c(用于外部摘要)

一般HMAC的计算,分为三个阶段,密钥处理、内部摘要和外部摘要。

  • 密钥处理 (key) HMAC对于密钥的形式并没有做限制,所以使用的密钥编码后,可能长度是不同的。如果超长,则需要使用摘要函数进行计算后作为新的密钥,然后再来检查长度,使用0进行补足。

  • 内部摘要 (inner) 内部计算先将密钥和0x36进行逐位异或操作,然后将原文附加到后面,计算其摘要值,然后取前L位,得到内部摘要(长度为L)

  • 外部摘要 (outter) 首先将密钥和0x5C进行逐位异或操作,然后将内部摘要附在后面(总长度为b+L),计算其摘要值,就得到最后的HMAC值,长度为32byte(摘要结果)。

完整代码

完整的实现和测试代码如下:

有一个非常有趣的网站,将这个算法高度可视化了,在自己编程和实验过程遇到问题的时候,也可以用来对照检查每一步操作的流程和数据,强烈推荐给读者。

sha256algorithm.com

附: 关于性能和优化

前面的讨论,我们已经大体上理解了SHA256计算的原理和流程,基本上就是各种位操作和整数的计算,示例代码已经尽量优化到不使用字符串进行操作。因此可以预计其性能不会太差。本来,SHA256的性能并不是人们关注的问题,但前几年,SHA256突然找到了一个超级重磅的应用场景:没错,就是比特币。比特币应用的核心,就是SHA256计算,而且就是用的标准的算法,并没有做任何优化和调整,这样SHA256计算的性能,就变成了技术焦点,一个可能影响价值成千上万亿美元的大生意!随后很快,虚拟币市场就展开了军备竞赛,人们在想方设法提升计算规模的同时,绞尽脑汁的寻求提高计算效率。

SHA256算法的设计,本身其实还是比较难被加速的,可并行和高效计算的环节并不多。但使用GPU来加速运算,也是非常可观的。当然效果最好的是FPGA,在电路和硬件级别进行优化,又快又省(图)。所以我们看到,现在比特币算力的核心,其实都是这种东西,你手里那个什么i7/i9,要来挖矿,其实就是一个笑话。还是专业的东西,干专业的事情吧。

Hash-rate-and-power-consumption-of-several-SHA-256-architectures.png

随着了解的深入和思考,笔者还发现,这个过程在软件处理上,确实是有优化的空间的。优化的方向可以包括处理效率和减少资源占用。

虽然主压缩过程是一个循环序列操作,很难并行处理;但是扩展过程却是有一定可能。因为这个阶段的主要目标是计算每一个压缩轮次的参数数组W,而这个W的生成,只和当前轮次的数据块的内容相关,这样其实是可以并行处理所有分段的,如果是一个多线程的系统,就可以先将数据分片,然后并行处理分片,最后进行压缩处理;甚至可以使用队列来协调所有处理过程,来进一步提高处理效率。

SHA256的算法设计,也比较难进行流化处理。所能够想到的就是读取数据,进行统计长度(补位操作)时,就先进行一次W数组转换,但不计算所有元素,只计算前16个。完成后,原始数据内容就可以释放了。然后在进入压缩阶段时,需要的时候,再进行展开计算处理。文中示例代码,在压缩阶段,已经很好的循环使用了中间计算变量了,这部分可进行内存优化的空间不大。