Redis源码中探秘SHA-1算法原理及其编程实现

本文基于Redis 2.8.19

导读

        SHA-1算法是第一代“安全散列算法”的缩写,其本质就是一个Hash算法。SHA系列标准主要用于生成消息摘要(摘要经加密后成为数字签名),曾被认为是MD5算法的后继者。如今SHA家族已经出现了5个算法。Redis使用的是SHA-1,它能将一个最大2^64比特的消息,转换成一串160位的消息摘要,并能保证任何两组不同的消息产生的消息摘要是不同的。虽然SHA1于早年间也传出了破解之道,但作为SHA家族的第一代算法,对我们仍然很具有学习价值和指导意义。        

        SHA-1算法的详细内容可以参考官方的RFC:http://www.ietf.org/rfc/rfc3174.txt

        Redis的sha1.c文件实现了这一算法,但该文件源码实际上是出自Valgrind项目的/tests/sha1_test.c文件(可以看出开源的强大之处:取之于民,用之于民)。它包含四个函数:

  • SHA1Init
  • SHA1Update
  • SHA1Transform
  • SHA1Final

SHA1算法流程概述

        sha-1算法大致分为5步:
  1. 附加填充位
  2. 附加长度 
  3. 初始化散列缓冲区 
  4. 计算信息摘要 
  5. 输出/返回 

附加填充位、长度

理论基础

       给消息 附加填充位使其模512与448同余(M%512 == 448)。即使满足了条件也要填充512位(比特)。填充过程是这样的:先补一位1,后面一律补0,直至满足条件。因此至少填充1位,最多填充512位。
       因为我们存储的时候是以字节为单位存储的,所以我们的消息的长度(单位:位)一定是8的倍数。而我们填充的时候也一定是8位,8位的来填充。也即不可能只填充一个二进制位,至少是8个二进制位(一个字节)。因此最少填充1个字节,最多填充64个字节(64*8=512)。
       在附加填充位完成之后,还要 附加长度,即附加64位数据来存储原始消息的长度。因为在附加填充位完成之后,消息长度(单位:位)是512与448同余,而此时再附加64位之后,消息长度就变成了512的整数倍。
       最后我们开始计算消息摘要的时候,就是每512位为一组开始计算的。

SHA_CTX结构

        SHA_CTX 结构在头文件sha1.h中定义:
typedef struct {
    u_int32_t state[5];
    u_int32_t count[2];
    unsigned char buffer[64];
} SHA1_CTX;
        它有三个成员,其含义如下:
成员 类型 说明
buffer unsigned char[64] 512(64×8)比特(位)的消息块(由原始消息经处理得出)
state u_int32_t[5] 160(5×32)比特的消息摘要(即SHA-1算法要得出的)
count u_int32_t[2] 储存消息的长度(单位:比特

SHA1Final

        SHA1Final()是整个算法的入口与出口。该函数通过调用该文件内其他函数完成了SHA-1算法的整个流程。它的声明如下:
void SHA1Final(unsigned char digest[20], SHA1_CTX* context);
        首先声明了三个变量:
    unsigned i;
    unsigned char finalcount[8];
    unsigned char c;
        后面是个条件测试宏,因为是 #if 0,所以我们只关注它 #else 的部分:
    for (i = 0; i < 8; i++) {
        finalcount[i] = (unsigned char)((context->count[(i >= 4 ? 0 : 1)]
         >> ((3-(i & 3)) * 8) ) & 255);  /* Endian independent */
    }
        首先我们注意到了有一句注释 Endian independent,直译是端独立,即该语句的结果与机器是大端还是小端无关。相信很多人在了解了大小端以后,在这里都会陷入迷惘。相反如果你不了解大小端的话,这条语句理解起来反而轻松。我们需要理解的是比如:unsigned int a = 0x12345678; unsigned int b = (a>>24)&255。无论机器是大端还是小端,b的值都是0x12(0x00000012)。大小端对于移位操作的结果并无影响,a>>24 的语义一定是a 除以 2的24次方。
        finalcount是char数组,context->count是整型数组。这段语句的意思就是将整型数据分拆成单个字节存储。finalcount 存储的结果可以理解为是一个大端序的的超大整型。举例比如:context->count[0]存储的是0x11223344,context->count[1]存储的是0x55667788。那么最后finalcount[0]~finalcount[7]存储的依次是:0x88、0x77、0x66、0x55、0x44、0x33、0x22、0x11
    c = 0200;
    SHA1Update(context, &c, 1);
        c的二进制表示为10 000 000。因为前面我讲解了,填充的时候是以字节为单位的,最少1个字节,最多64个字节。并且第一位要填充1,后面都填充0。所以拿到一个消息我们首先要给他填充一个字节的10 000 000. SHA1Update() 函数就是完成的数据填充(附加)操作,该函数具体细节容后再禀。这里我们先关注整体结构。
    while ((context->count[0] & 504) != 448) {
	c = 0000;
        SHA1Update(context, &c, 1);
    }
        这段代码很容易看出它的功能就是:循环测试数据模512是否与448同余。不满足条件就填充全一个字节0。细心的你也许会发现这里的条件是不是写错了:
while ((context->count[0] & 504) != 448) 
//你觉得应该是
while ((context->count[0] & 511) != 448)
        理论上来说,你的想法确实不错。不过源码也没问题,我们可以用bc来看一下这两个数的二进制表达:
111111000    //504
111111111    //511
       可以看出它们的不同之处就是最后三位。504后三位全0,511后三位全1。context->count中存储的是消息的长度,它的单位是:位。前面我们提到了我们的数据是以字节来存储的,所以context->count[ ]中的数据肯定是8个倍数,所以后三位肯定是000。所以不管是000&000,还是000&111其结果都是0。
------------------------------------------------------------------------------------------------------------------------- -------------------------------------
       虽然 &504和 &511在这里效果相同,但是&504可读性很差。而之所以会出现可读性这么差的代码,我的猜想是效率。下面是我的猜想,未验证。假设一个数A,当A和000...(全0)进行&操作的时候的时候,其结果必然是0(编译器可能直接判断为0,而不去理会A的值)。而当A和111...(全为1)的数进行&操作的时候,其结果是A的值,所以要进行一下copy,将A返回;又或者编译器是逐位判断的,所以A每一位和1进行&的时候,编译器都要去查看一下A对应位上的值,而与0进行&的时候,则直接设置结果为0.当然了,这只是我的猜想,正确与否请各位指正。
------------------------------------------------------------------------------------------------------------------------- -------------------------------------
SHA1Update(context, finalcount, 8);  /* Should cause a SHA1Transform() */
        很明显,这一句完成的就是附加长度了。根据注释可以看出,这将触发SHA1Transform()函数的调用,该函数的功能就是进行运算,得出160位的消息摘要(message digest)并储存在context-state[ ]中,它是整个SHA-1算法的核心。其实现细节请看下文的: 计算消息摘要一节。  
    for (i = 0; i < 20; i++) {
        digest[i] = (unsigned char)
         ((context->state[i>>2] >> ((3-(i & 3)) * 8) ) & 255);
    }
        最后的这步转换将消息摘要转换成单字节序列。用代码来解释就是:将context-state[5]中储存的20个字节(5×4字节)的消息摘要取出,将其存储在20个单字节的数组digest中。并且按大端序存储(与之前分析context->count[ ]到finalcount[ ]转换的思路相同)。SHA-1算法最后要得出的就是这160位(20字节)的数据。

SHA1Update      

        SHA1Update()前面我提到了,它完成的操作就是将新数据(原始数据、填充位、长度)依次附加到context->buffer[ ]中。  
void SHA1Update(SHA1_CTX* context, const unsigned char* data, u_int32_t len);
        data就是我们要附加的数据。len是data的长度(单位:字节)
    j = context->count[0];
    if ((context->count[0] += len << 3) < j)
        context->count[1]++;
    context->count[1] += (len>>29);
       context->count[ ]存储的是消息的长度,超出context->count[0]的存储范围的部分存储在context->count[1]中。len<<3就是len*8的意思,因为len的单位是字节,而context->count[ ]存储的长度的单位是位,所以要乘以8。 if ((context->count[0] += len << 3) < j) 的意思就是说如果加上len*8个位,context->count[0]溢出了,那么就要:context->count[1]++;进位。
       len<<3的单位是位,len>>29(len<<3 >>32)表示的就是len中要存储在context->count[1]中的部分。
    j = (j >> 3) & 63;
        j>>3获得的就是字节数,j = (j >> 3) & 63得到的就是低6位的值,也就是代表64个字节(512位)长度的消息。,因为我们每次进行计算都是处理512位的消息数据。
    if ((j + len) > 63) {
        memcpy(&context->buffer[j], data, (i = 64-j));
        SHA1Transform(context->state, context->buffer);
        for ( ; i + 63 < len; i += 64) {
            SHA1Transform(context->state, &data[i]);
        }
        j = 0;
    }
    else i = 0;
    memcpy(&context->buffer[j], &data[i], len - i);
        这段代码大致的含义就是:如果j+len的长度大于63个字节,就分开处理,每64个字节处理一次,然后再处理后面的64个字节,重复这个过程;否则就直接将数据附加到buffer末尾。逐句分析一下:
        memcpy(&context->buffer[j], data, (i = 64-j));
        SHA1Transform(context->state, context->buffer);
        i=64-j,然后从data中复制i个字节的数据附加到context->buffer[j]末尾,也就是说给buffer凑成了64个字节,然后执行SHA1Transform()来开始一次消息摘要的计算。
        for ( ; i + 63 < len; i += 64) {
            SHA1Transform(context->state, &data[i]);
        }
        j = 0;
        然后开始循环,每64个字节处理一次。这里可能有朋友会好奇每次i递增的步长都是64,那么为什么比较的时候是 i + 63 < len;而不是 i + 64 < len;呢?其原因很简单——因为下标是从0计数的。这些细节大家简单琢磨一下就OK啦。
        最后j=0,把buffer[ ]的偏移重置到开头。因为已经计算完消息摘要的数据就没有用了。
    else i = 0;
    memcpy(&context->buffer[j], &data[i], len - i);
       如果前面的if不成立,那么也就是说原始数据context->buffer加上新的数据data的长度还不足以凑成64个字节,所以直接附加上data就行了。相当于:memcpy(&context->buffer[j], &data[i], 0);
       如果前面的if成立,那么j是等于0的,而 i 所指向的偏移位置是 (└ len/64┘×64,len)之间。  └   ┘表示向下取整。

初始化散列缓冲区

        SHA-1算法需要使用两个5个字的缓冲区,第一个5字缓冲区被在RFC文档中被标识为A、B、C、D、E,第二个5字缓冲区被被标志为H0~H4。在后面的每一轮的计算开始的时候,都要把H0~H4依次赋值给A~E。然后在每轮计算结束之后再更新H0~H4的值。下面是H0~H4的初始值:

      H0 = 0x67452301

      H1 = 0xEFCDAB89

      H2 = 0x98BADCFE

      H3 = 0x10325476

      H4 = 0xC3D2E1F0
        在开始计算消息摘要之前,要先初始化这5个字的缓冲区,也就是按照上面的数值赋值。这步操作体现在sha1.c文件的 SHA1Init()函数中。
void SHA1Init(SHA1_CTX* context)
{
    /* SHA1 initialization constants */
    context->state[0] = 0x67452301;
    context->state[1] = 0xEFCDAB89;
    context->state[2] = 0x98BADCFE;
    context->state[3] = 0x10325476;
    context->state[4] = 0xC3D2E1F0;
    context->count[0] = context->count[1] = 0;
}


计算消息摘要

理论基础

        将长度512的消息块(M1、M2、……Mn)依次进行处理,处理每个消息块Mi都需要运行一个80轮运算的函数,每一轮都把160比特缓冲区的值ABCDE作为输入,并更新缓冲区的值。
        每一轮需要用到一个非线性的函数f(t):
下面内容取自官方RFC文档,注意括号中的t并不是输入参数。可以理解为f函数的下标。共有四种f函数。
      f(t;B,C,D) = (B AND C) OR ((NOT B) AND D)         ( 0 <= t <= 19)

      f(t;B,C,D) = B XOR C XOR D                        (20 <= t <= 39)

      f(t;B,C,D) = (B AND C) OR (B AND D) OR (C AND D)  (40 <= t <= 59)

      f(t;B,C,D) = B XOR C XOR D                        (60 <= t <= 79).
        每一轮还会用到一个附加常数K(t):
      K(t) = 0x5A827999         ( 0 <= t <= 19)

      K(t) = 0x6ED9EBA1         (20 <= t <= 39)

      K(t) = 0x8F1BBCDC         (40 <= t <= 59)

      K(t) = 0xCA62C1D6         (60 <= t <= 79)
        共有5步:
1. M(t) = W(t) (0<= t<= 15)
2. W(t) = S^1(W(t-3) XOR W(t-8) XOR W(t-14) XOR W(t-16)) (16<= t <= 79,S^1()表示循环左移1位)
3. A = H0, B = H1, C = H2, D = H3, E = H4. 
4. 对于(0<= t <= 79)开始执行80轮变换
    TEMP = S^5(A) + f(t;B,C,D) + E + W(t) + K(t); 
    E = D; D = C; C = S^30(B); B = A; A = TEMP;
5. H0 = H0 + A, H1 = H1 + B, H2 = H2 + C, H3 = H3 + D, H4 = H4 + E.
        上面的数学表达式改编自RFC文档, 在经过80轮运算之后的H0~H4就是SHA-1算法要生成的160比特(位)的消息摘要。里面使用了ABCDE这5个符号,Redis源码中使用的是v表示符号A;w、x、y代表上文中的B、C、D,z表示上文中的TEMP。在5步之中的前面两步中,进行了消息块M(i)到W(i)的转换,这样做的目的是将16个字(32位)的消息块(M)转换成80个字的字块(W)。

编码实现基础

宏:rol

#define rol(value, bits) (((value) << (bits)) | ((value) >> (32 - (bits))))

        将32位整型value进行循环左移bites位。所谓循环左移,就是将左边被移出的位补到数据的右边。汇编中有个ROL指令就是循环左移。

共用体变量:block

        block会在接下来介绍的两个宏函数中用到,它是在函数 SHA1Transform()中声明的一个变量(因为宏实际上进行的是编译期间的替换操作,所以可以在未声明的时候前向引用)。
    typedef union {
        unsigned char c[64];
        u_int32_t l[16];
    } CHAR64LONG16;
#ifdef SHA1HANDSOFF
    CHAR64LONG16 block[1];  /* use array to appear as a pointer */

        可以看出block虽然是数组,但只有一个元素。注释中也标注了用数组类型是为了让它能表现的像指针一样。它的大小是64个字节(512位)。SHA1算法中有 “字”(W)概念,一个字是32位,换句话说就是16个字的大小。下文中我会 用W来表示block-l[ ]:
W(i) = block-l[i&15] // 16<= i <= 79

宏:blk0

#if BYTE_ORDER == LITTLE_ENDIAN
#define blk0(i) (block->l[i] = (rol(block->l[i],24)&0xFF00FF00) \
    |(rol(block->l[i],8)&0x00FF00FF))
#elif BYTE_ORDER == BIG_ENDIAN
#define blk0(i) block->l[i]
        blk0的功能实际是就是进行字节序的转换。如果是小端序就将block->l[i] 转换为大端序(上面代码中的第2行),如果是大端序就不操作,直接等价于block->l[i]。 实际上在调用blk0(i)的时候,它参数i的取值范围是0~15。

宏:blk

#define blk(i) (block->l[i&15] = rol(block->l[(i+13)&15]^block->l[(i+8)&15] \
    ^block->l[(i+2)&15]^block->l[i&15],1))
         实际上在调用blk(i)的时候,它的参数i的取值范围是16~79。 实际上它实现的功能我们在下面会用到,它实际计算的表达式是:
用符号W(i)来表示block-l[i]
W(i) = S^1( W(i-3) XOR W(i-8) XOR W(i-14) XOR W(i-16) )  //S^1()表示循环左移
因为观察上面表达式可知,我们只需要16个字的存储空间来保存就可以了。所以在实现上等价与:
W(i%16) = S^1( W((i-3)%16) XOR W((i-8)%16) XOR W((i-14)%16) XOR W((i-16)%16) )
        下面来简单证明一下:
因为a&15等价与a%16
则源码blk(i)完成的操作等价于:
W(i%16) = S^1( W((i+13)%16) XOR W((i+8)%16) XOR W((i+2)%16) XOR W((i%16)) )
  
设m+n=16
(i+n)%16
= (i+16-m)%16
= ((i-m)%16 + 16%16)%16
= (i-m)%16
当n=13,8,2,0时,m等于3,8,14,16
所以:
W(i%16) = S^1( W((i+13)%16) XOR W((i+8)%16) XOR W((i+2)%16) XOR W((i%16)) )
W(i%16) = S^1( W((i-3)%16) XOR W((i-8)%16) XOR W((i-14)%16) XOR W((i-16)%16) )
等价

开始计算

        我们查看sha1.c的源码,就能发现几个宏函数:
/* (R0+R1), R2, R3, R4 are the different operations used in SHA1 */
#define R0(v,w,x,y,z,i) z+=((w&(x^y))^y)+blk0(i)+0x5A827999+rol(v,5);w=rol(w,30);
#define R1(v,w,x,y,z,i) z+=((w&(x^y))^y)+blk(i)+0x5A827999+rol(v,5);w=rol(w,30);
#define R2(v,w,x,y,z,i) z+=(w^x^y)+blk(i)+0x6ED9EBA1+rol(v,5);w=rol(w,30);
#define R3(v,w,x,y,z,i) z+=(((w|x)&y)|(w&x))+blk(i)+0x8F1BBCDC+rol(v,5);w=rol(w,30);
#define R4(v,w,x,y,z,i) z+=(w^x^y)+blk(i)+0xCA62C1D6+rol(v,5);w=rol(w,30);
        这段代码中的R2、R3、R4三个宏函数,所完成的操作就是 t (t表示轮数,共计80轮运算) 在范围 [20,39]、[40,59]、[60,79]的时候的运算。对应RFC文档中:求解TEMP、给A~E重新赋值。但是我们可以看到当 t 的范围在[0,20]的时候使用了R0和R1这两个宏函数来表示,它们的差别之处在于R0里面计算 z(即TEMP)值的时候,加上的是blk0(i),而R1中加的是blk(i) (和R2、R3、R4一样,加的是blk(i))。造成这个差别的原因是在前面提到了5步运算的前两步中:当 t 取值[0,15]的时候W(t)直接等于M(t),而 t>15以后(这里是t取值[16,19])Wt则需要进行转换才能得到,即 
W(t) = S^1(W(t-3) XOR W(t-8) XOR W(t-14) XOR W(t-16))
前面我们已经证明了,blk(i)实现的功能正是这个公式的计算。
        大家如果细心的话,可以发现R0和R1中使用的 f 函数是: (w&(x^y))^y 而RFC文档中给出的是(B&C)|(B&D) 用wxy替换BCD的话就是  (w&x)|(w&y)。那么 (w&(x^y))^y(w&x)|(w&y)等价吗?答案是肯定的,逻辑表达式的证明也不是我的强项,不过因为只涉及三个变量所以我们可以用枚举的方法来比较两个逻辑表达式的值,于是我手写了一个小程序来比较它们:
#include <stdio.h>
int main(){
    for(int w=0;w<2;w++){
        for(int x=0;x<2;x++){
            for(int y=0;y<2;y++){
                printf("-------------\n");  //分割线,使更容易比较阅读
                printf("%d %d %d:%d\n",w,x,y,(w&(x^y))^y);
                printf("%d %d %d:%d\n",w,x,y,(w&x)|(~w&y));
            }
        }
    }
}
        谈了这么多,是时候引入sha1.c文件的核心函数了——SHA1Transform()

SHA1Transform()

void SHA1Transform(u_int32_t state[5], const unsigned char buffer[64])
        该函数的代码行数较多,为节省篇幅,我这里就不全部列出了。
       开始部分声明了u_int32_t类型的五个变量:a、b、c、d、e。接着定义了结构体类型CHAR64LONG,并声明了一个该类型的指针变量block(实际是数组实现),前面有介绍。然后:
memcpy(block, buffer, 64);
将参数buffer里面的字节复制到block中。
    a = state[0];
    b = state[1];
    c = state[2];
    d = state[3];
    e = state[4];
         实际上完成的就是RFC文档中的H0~H4赋值给ABCDE的操作。接下来就是80轮运算的代码。每20轮为一组,共分四组。
    R0(a,b,c,d,e, 0); R0(e,a,b,c,d, 1); R0(d,e,a,b,c, 2); R0(c,d,e,a,b, 3);
    R0(b,c,d,e,a, 4); R0(a,b,c,d,e, 5); R0(e,a,b,c,d, 6); R0(d,e,a,b,c, 7);
    R0(c,d,e,a,b, 8); R0(b,c,d,e,a, 9); R0(a,b,c,d,e,10); R0(e,a,b,c,d,11);
    R0(d,e,a,b,c,12); R0(c,d,e,a,b,13); R0(b,c,d,e,a,14); R0(a,b,c,d,e,15);
    R1(e,a,b,c,d,16); R1(d,e,a,b,c,17); R1(c,d,e,a,b,18); R1(b,c,d,e,a,19);
    ...
        第一组比较特殊,使用了R0和R1两个宏函数,其原因前面已经介绍了。因为第0~15轮运算和16~79轮运算的时候消息块M(i)和字块W(i)的转换是不一样的。后面的20~39轮,40~59轮,60~79轮就是依次使用的R2,R3,R4来运算了,比较好理解,就不表了。接着:
    state[0] += a;
    state[1] += b;
    state[2] += c;
    state[3] += d;
    state[4] += e;
    /* Wipe variables */
    a = b = c = d = e = 0;
        完成的就是更新缓冲区H0~H4的内容。然后把a~e清空为0(这一步我感觉意义不到,本身就是栈存储的5个变量,函数结束后就释放了啊)。 最后state[0]~state[4]中存储的就是SHA-1算法的生成的消息摘要。

参考资料

SHA-1官方RFC文档: http://www.ietf.org/rfc/rfc3174.txt
姚永雷,马利. 计算机网络安全(第二版). 北京:清华大学出版社,2011

你可能感兴趣的:(Redis源码中探秘SHA-1算法原理及其编程实现)