常用加密算法分析和实现

一、前言

工作中有时候需要对数据进行加密,就笔者从事的Android开发来说, 上层开发语言为Java/Kotlin,JDK本身提供了一套加解密接口,可以很方便地调用加密/解密方法。
但有时候需要在native层实现加密逻辑的封装,这时候只能找C/C++的实现了。

当前的加密算法可以大约分为三类:

  • 散列算法:MD5,SHA家族等;
  • 对称加密:AES,DES等;
  • 非对称加密:RSA,DSA,DH等。

MD5/SHA/AES等实现比较简单,也很好找到,但是对于RSA就没那么好找了。
之前笔者就遇到需要在native层实现RSA场景。
去github上找代码,很多都是简单的demo演示(用long实现,几十bits的密码长度);
有的实现了大数,但是效率很低;
而openssl等加密库,则是牵涉很多文件,想抽取其中的实现,却无从下手。
最后我想了个workaround的办法:通过在Java的层封装Util类来调用JDK的RSA实现,然后在native层调用Util类的方法。

再后来遇到的场景是,各个端需要用C++统一部分业务逻辑的实现。
不巧的是,这部分逻辑也用到了RSA。
这回就不能反射调用Java来实现了,因为IOS和PC的平台不依赖JVM。
最终同事找了个C++的加密库,虽然该加密库可读性不好,文件很多,但总归依赖比openssl要少。

在那之后,我开始在关注常用的加密方法的实现,先后收集了AES、SHA、ECC等加密方法的比较好的C语言实现(执行快,依赖少)。
而RSA也通过参考JDK的实现,以及查阅相关资料,实现了C语言的版本。
最终,完成了包含AES-CBC, SHA256, HMAC-SHA25, RSA, ECDH, ECDSA等加密方法的收集。
代码已上传Github, 有需要的朋友可自行获取。
项目地址:https://github.com/BillyWei01/EasyCipher

在收集的过程中查阅了一些资料,梳理了一些知识点,这里和大家分享一下。

二、散列算法

散列函数(也叫哈希函数)的功能是把任意长度的输入,通过哈散列算法,变换成固定长度的输出(哈希值)。
这种转换是一种压缩映射,也就是,哈希值的空间通常远小于输入的空间,不同的输入可能会散列成相同的输出。
有的说法是哈散列算法不算加密算法,因为通过哈希值无法还原唯一的原始输入。
严格来说确实如此,但是不能否认的是,哈希算法确实是用在了许多确保数据安全的地方,所以从这个角度来说,将其归类为加密算法也没太大问题。

并非所有的哈希函数都适用于加密,比如普通的用于计算哈希表索引的函数就不适用。
用于加密的哈希函数通常需要具备一下特性:

  • 抗原像攻击性: 对于随意给定的h,找到满足H(x)=h的x在计算上不可行。
  • 抗弱碰撞性: 对于随意给定的数据块x,找到满足H(y)=H(x)的y ≠ x在计算上不可行。
  • 强抗碰撞性:找到满足H(x) = H(y)的随意一对(x,y)在计算上不可行。

比方说JDK计算字符串hash的函数:

    public int hashCode() {
        int h = hash;
        if (h == 0 && value.length > 0) {
            char val[] = value;

            for (int i = 0; i < value.length; i++) {
                h = 31 * h + val[i];
            }
            hash = h;
        }
        return h;
    }

这个函数很显然就不具备抗原像攻击性,比方说当字符串为”A“,其哈希值为65,也就是字符‘A’的ASCII码值。

而像MurmurHash这样的哈希函数,有比较好的抗原像攻击性,但不具备抗碰撞性。
当然,大家一般也不会用这类函数来做“加密”,而仅仅是用来计算哈希表索引。
但仅用来计算索引这样的情况,也会因为不具备抗碰撞性而攻击,比如《 由MurmurHash2算法碰撞引起的Redis DDos攻击漏洞》这篇文章所述,攻击者可以计算大量的具备相同哈希值的输入,造成redis哈希表的性能退化,从而使其无法正常提供服务。
为了解决这类攻击,JDK 1.8的方案是当hash冲突过多时,将链表转换为平衡树,从而避免性能退化。
而redis的解决方案则是换一个哈希算法:SipHash。
SipHash能够有效减缓hash flooding攻击。凭借这一点,它逐渐成为 Ruby、Python、Rust 等语言默认的哈希表实现的一部分。

能做到减缓碰撞哈希值的构造,已经普通哈希算法是其极限了。
要做“加密”,还是需要强度足够的哈希算法。
MD5是广泛使用的加密哈希,但在2004被“破解”(在一定时间内找到碰撞,并非找到原始输入);
而SHA-1目前也是被“破解”了,很多浏览器已经不支持包含SHA-1的加密套件了。
目前广泛使用的加密哈希为SHA-2家族,其中用的最多的是SHA-256。

虽然加密哈希具备较高强度的抗原像攻击性和抗碰撞性,但是仅靠其特性并不能确保数据安全。

由于输入x,会得到确定的输入y, 所以对于一些较短的输入,可能被暴力破解,或者字典攻击。
如果是登录系统,对应的策略可以是:

  • 提高复杂度,比方说很多地方需要输入包含“大小写字母和字符”之类的密码;
  • 限制登录次数;
  • 用比较耗时的哈希函数(如bcrypt)等。

暴力破解不单单针对哈希加密,对称加密同样有可能遭受暴力破解。
有一类攻击是仅针对哈希算法的:彩虹表攻击。
对此类攻击的对应方法之一是“加盐”。
网站记录用户密码最好是记录加盐的哈希,万一不幸被拖库了也不至于泄漏用户密码。
多年前有一些网站被曝泄漏用户的账号密码,甚至有的网站保存的是用户的明文密码!有的用户可能多个网站用同一个密码,在一个网站泄漏了明文密码,会波及其他网站注册的账号。
好在涉及交易和支付的系统都会设计二次认证,否则影响会更严重。

加密哈希有一种使用场景:消息认证码 (Message Authentication Code,简称MAC)。
MAC使用不当的话也会遭受攻击,比如长度扩展攻击。
业界常用的MAC算法是HMAC。

HMAC过程示意图如下:

i_pad是重复0x36,o_pad是重复0x5C, 而SHA1的块大小为64字节(MD5和SHA256也是64字节),所以是重复64次。
如果key长度大于64字节,则计算哈希值作为key。
简单概括,HMAC就是计算 H(key XOR o_pad, H(key XOR i_pad, message))
在key小于块大小时,只需要计算两次哈希,速度很快。
像RSA结合SHA也可以对消息“签名”,但是RSA运算比较耗时;
所以在加密通信中通常用非对称加密来协商密钥,后续会话用协商的密钥来做对称加密和HMAC运算。

三、对称加密

对称加密有很多种实现,这里只讲一下目前使用比较广泛的是AES算法。
AES是一种块加密算法,块大小为16字节。
其核心部分(块加密的部分),输入和输出都是16字节,换种说法就是,函数的输入和输出是一一映射的。
相对的,哈希运算的输入空间大小不限,输出空间是固定长度(所以仅凭hash值无法计算唯一的输入值)。

AES的块加密部分,需要经过多轮运算,密钥长度(可以是128, 192, 256比特)越长,执行的轮数越多;
每轮运算有四个子运算:字节替代、行移位、列混淆、 轮密钥加。

  • 字节替代(SubBytes)
    通过查表(S盒)的方式,以字节为单位,用另一个字节替换当前字节。
    这有点类似于代码混淆,却别在于代码混淆是用另外一个字符串替换当前的字符串。
    经过混淆后,原文本来具备可读性变得不可读了;
    但是混淆后模式没有改变,比如原来方法foo()混淆后变为a(), 则混淆后所有调用foo()的地方都是a()。
    也就是,字节替代后仍然存在统计特征。

  • 行位移(ShiftRows)
    行位移比较简单,就是将16字节作为一个4行4列的矩阵,
    其中1、2、3行分别左移(逆运算为右移)1、2、3个字节。

  • 列混淆(MixColumns)
    和行位移一样,将16字节划分为4行4列;
    不同的是,列混淆是分别对每一列的4个字节做运算(和一个4x4的矩阵左乘)。
    解密时也是左乘矩阵,解密矩阵是加密矩阵的逆矩阵。
    矩阵运算需要进行加法运算和乘法运算,计算机的直接整数相加和直接整数相乘可能会溢出,从而丢失信息,使得计算不可逆,所以AES将列混淆放到“有限域”中做运算。
    行位移和列混淆共同提供了算法的扩散性
    扩散的目的是让明文中的单个数字影响密文中的多个数字,从而使明文的统计特征在密文中消失。
    如果只有列混淆运算,则最终的效果是 [0,3] , [4,7], [8,11], [12,15] 四个分组分别扩展;
    加上了行位移,才可以达到[0, 15]的字节的扩散效果(明文一个字节改变,密文16个字节全都会变化)。
  • 轮密钥加(AddRoundKey)
    轮密钥加是四个字运算中最简单的,具体就是16个字节分别和轮密钥做异或运算。
    轮密钥是通过原始密钥通过扩展密钥计算得到,确保每一轮的密钥都不相同。
    结合密钥的运算,提供了算法的保密性。
    如果没有密钥参与运算,则前面的运算都只是“编码”而已(类似于base64), 每个人都可以“解码”,那就不是“加密”了。

当明文长度大约16字节是,就需“分组”了,每16字节一组;
明文长度不一定是16的倍数,所以最后一组需要“补齐(padding)”, 通常大家用的是“PKCS5Padding”或者“PKCS7Padding”,具体做法是:
最后一组缺n个字节凑够16,则最后的n个字节都填n; 如果明文刚好是16字节的倍数,则在末尾增加16字节,并全部填充16。

关于“PKCS5Padding”和“PKCS7Padding”的区别:
严格来讲PKCS7Padding是以块大小来对齐,而PKCS5Padding则定义块大小是8字节;
但是对于AES来说,块大小固定为16字节,所以其实两者没有区别。
IOS只支持PKCS7Padding,Oracle JDK只支持PKCS5Padding,而Android SDK两者都支持,而且加密结果是一样的。

对明文分组和补齐之后,还要选择“模式“。
最简单的ECB模式,每个块独立加密/解密:

ECB模式的缺点是:
1、相同的明文会得到相同密文;
2、明文中的重复内容会在密文中有所体现。
一般不建议用此模式。

另一个比较简单且常用的是CBC模式:

CBC模式引入了随机的初始向量(IV),并且以块加密的结果作为下一个加密块的初始向量,从而使得每次加密的密文都不相同,相同的块也不会得到相同的密文。
CBC模式克服了ECB模式的安全性问题,而且操作简单,是比较常用的加密模式之一。

四、非对称加密

非对称加密常见的有RSA,DSA,DH等算法,不过他们所解决的问题有所不同。
DH算法解决密钥协商的问题,DSA解决数据真实性和完整性的问题(签名),
而RSA则本身能够加密和解密,既可以用于密钥协商,也可以用于签名。

RSA算法是各类非对称加密中原理比较清晰的。
其数据证明过程就不具体介绍了,这里我们简单讲一些其计算过程。

下面是计算过程的代码演示:
(只是过程演示,实际实现中,基本类型的精度无法满足,需要用到大数运算)

void rsa_test() {
    i64 p = 13;
    i64 q = 17;
    i64 n = p * q;
    i64 r = (p - 1) * (q - 1);
    i64 e = 7;
    i64 d = inv(e, r);
   
    i64 x  = 100;
    i64 m = modPow(x, e, n);
    i64 x0 = modPow(m, d, n);
}

步骤讲解:

  1. 选取两个大素数p, q。
    选取大质数通常需要用到Miller-Rabin算法。
  2. 计算n=pq,r=(p-1)(q-1)。
  3. 随机选取正整数1 很多实现中, e会直接选取65537, 因为65537是个质数,必然和r互质,
    而且65537的的二进制是0x10001,只有两个‘1’,做加解密时相关的运算会比较快。
    e和n构成公钥
  4. 计算d,满足de≡1(mod r) 。
    上式用到了同余表示,也就是de%r=1%r (de和1除以r的余数相同);
    因为1%r=1, 所以最终就是de%r = 1。
    这一步通常用扩展欧几里得算法求取d。
    d和n构成私钥
  5. 加密:m=x^e mod n (注:此处的“^”是指乘方,不是异或)。
  6. 解密:x0=m^d mod n

RSA算法加密和解密的形式是相同的,都是求取 m = a^b % n。
通常RSA的实现中e和d(指数)都不小,所以先计算乘方再取余是不可行的,可以通过快速幂算法来求取m。
而快速幂的计算过程中需要频繁地做除法,所以通常会用蒙哥马利算法来计算m。

由加解密的形式我们可以得到两点信息:

  1. 公钥可以用来加密明文,也可以用来解密私钥所加密的密文,反之亦然。
    值得注意的是,用openssl或者jdk等生成的密钥对,公钥的e通常是固定的65537, 随意千万不要图公钥计算快而作为服务端的密钥,把私钥放客户端。
    而且,私钥文件中通常包含了有e,d,n(包含了公钥和私钥的计算要素)。

  2. 输入x必须小于n。
    本质上RSA的加密也是一个“输入到输出的一一映射”。
    相比于AES,AES的输入空间是16字节,而RSA的输入空间是[0,n)。
    实际上,在RSA的标准实现中,合法的输入空间要比n要少几个字节。
    我们通常说的1024, 2048(bit)长度的密钥,指的是n的长度。

RSA的加密块长度等于密钥长度,其格式为: [ 0x0 | BT | PS | 0x0 | DATA ] 。

  • BT:块类型(block type),私钥加密时BT=01 ,公钥加密时BT=02 。
  • PS:填充字符串(padding string),基于安全性的考虑,规定填充字符串不少于8字节。公钥加密时填充非零随机字节,如此,即使明文相同,每次加密后密文都不相同; 私钥加密时填充0xFF,解密后可以检查一下PS部分是否都是0xFF, 若不是则说明加密块非法。
  • DATA:明文,所以明文长度不能超过“密钥长度-11字节”。

关于RSA最后一点:RSA的密钥格式。
RSA常见的密钥格式有PKCS#1,PKCS#8,x509等。
比如,用openssl命令生成的密钥文件就是PKCS#1格式的。

// 生成私钥
openssl genrsa -out private_pkcs1.pem 2048
// 导出公钥
openssl rsa -in private_pkcs1.pem -out public_pkcs1.pem -pubout -RSAPublicKey_out

用文本文件打开公钥文件,可以看到如下格式的文本:

-----BEGIN RSA PUBLIC KEY-----
MIIBCgKCAQEAxFEkH3FTCGFRtCnLydJES+ShgmVjY7w3KwQxw9IVW+4p4mLL4V/+
p/m8pnoEaelVKX8fDxoWcJQQ2APGobMJ32MZkpWkFurSj2M5HlxLlH8hJNPYTHoN
UNh2SFeUtM1GkH9jyJRKqKS0qkJl6jXJGRRcKklNlYchIUdC2i+zqXoZw1KOva85
ISpU5Od3oZEeOqXtrC/OSzcTHNc1EdpyqpUpGZpPoFUHZ/Y9c0cn9Mvfw/S4BEua
rHyfB8YiValNzk4QWKCvokeH7OosSboGDu68j5AVmEHxxedD/FodQAONgXy6HSws
q5GkXYbW6gSWF7MG4o81wDn7hBpUGlsuxwIDAQAB
-----END RSA PUBLIC KEY-----

去掉首尾的标签和换行符,剩下的是一串base64字符串;
对其进行base64解码,得到由ANS.1格式的公约编码。
ANS.1是一种TLV形式的序列化格式(和protobuf有一点相似)。
将以上公钥格式化显示如下:

30 82 01 0a
   02 82 01 01
      00c451241f7153086151....省略部分字节
   02 03
      010001

对应PKCS#1形式的RSA公钥的定义:

RSAPublicKey ::= SEQUENCE {
   modulus           INTEGER,  -- n
   publicExponent    INTEGER   -- e
}

我们就可以得到公钥的“阶”和“模”了(e和n)。
获取私钥的d和n也是类似。
关于密钥格式的更多细节,可参考这篇文章:RSA密钥格式解析。

结合前面RSA计算过程分析,我们用openssl生成密钥的话,就省去了大数选择和密钥的计算了。
接下来只需实现modPow函数即可。
笔者在Github找了一圈大数实现,没找到满意的,最终把目光投到JDK的大数类(BigInteger), 参照其实现,用C语言实现了modPow函数,然后结合key解析和padding,实现了完整的RSA加解密。

在非对称加密算法中,除了RSA之外,被提的比较多的应该就是ECC了。
ECC是Elliptic Curve Cryptography(椭圆曲线加密)的缩写,其实ECC主要是定义了一种封闭运算,基于ECC的ECDH和ECDSA才是非对称加密算法。

提到ECDH,需要先说DH算法。
不像RSA可以“加密”,DH算法本身不能加密,而是用于协商密钥。
其简单演示如下:

  1. 设定公共因子p;
  2. 客户端计算A=a * p,发送A给服务端(这里的*代表一种运算,并非乘法);
  3. 服务端计算B=b * p, 发送B给客户端;
  4. 客户端计算k1 = a * B = a * b * p;
  5. 服务端计算k2 = b * A = b * a * p;
  6. 于是服务队和客户端拿到约定的密钥abp,然后可以用来做对称加密或者HMAC等运算。

算法的有效性需要两个条件:

  1. 计算a*p得到A比较容易,根据A和p计算a不可行(单向函数);
  2. 运算*具备交换律,以确保 a * b * p = b * a * p。

常规的乘法运算具备交换率,但是不满足第一个条件,因为通过除法运算可以计算a;
经典的DH算法,其运算为 y = (g ^ x) %p ,公共因子为g, p。
该运算满足交换律;
同时,当g, p 为较大的数值时,计算y = (g ^ x) % p相对容易(和RSA一样,实现modPow函数即可),但根据g, p, y 计算x却很困难。
但是为确保安全性,p需要取较大的数值,比如1024bit的大整数,计算速度较慢(类似于RSA)。
而ECC所定义的封闭运算,所构造的单向函数的逆向成本要比mowPow要高,所以密钥长度不需要很长,计算速度较快。
基于ECC运算的DH算法,被称为ECDH;类似的,基于ECC运算的DSA算法被称为ECDSA。
而ECDHE算法,其中的E是ephemeral(临时性的),也就是,每次使用都重新生成密钥。
网络传输中,利用ECDHE可以实现通信的“向前安全”。
所谓的“向前安全”,就是指攻击者记录每次双方的通信,在某一天获取到私钥时,是否能解码之前记录的通信。
假如每次所用的密钥对都是临时的,那么攻击者是无法解密之前的通信的。
ECDHE生成密钥对很快,所以生成临时密钥的代价是可以接受的。

五、总结

本文比较概要地梳理了常见加密算法的一些知识点。
关于原理的部分也是简要的描述,背后的数学原理之类的就不深入了,感兴趣的朋友可以阅读文末的参考链接。


参考资料

[1] 安全散列函数 https://www.cnblogs.com/cxchanpin/p/7141815.html
[2] HMAC算法及计算流程介绍 https://zhuanlan.zhihu.com/p/336054453
[3] AES简介 https://www.cnblogs.com/luop/p/4334160.html
[4] AES算法描述及C语言实现 https://blog.csdn.net/shaosunrise/article/details/80219950
[5] 伽罗瓦域上的四则运算 https://blog.csdn.net/shelldon/article/details/54729687
[6] 另一种世界观——有限域 https://www.bilibili.com/read/cv2922069
[7] RSA算法正确性证明 https://zhuanlan.zhihu.com/p/48994878
[8] 快速幂算法 https://blog.csdn.net/qq_19782019/article/details/85621386
[9] 蒙哥马利算法 https://blog.csdn.net/weixin_46395886/article/details/112988136
[10] RSA密钥格式解析 https://www.jianshu.com/p/c93a993f8997
[11] RSA加密的填充方式 https://blog.51cto.com/u_13520299/2656705
[12] DSA-数据签名算法 https://blog.csdn.net/aaqian1/article/details/89299520
[13] Diffie-Hellman密钥交换 https://juejin.cn/post/6844903881093169159
[14] ECDHE密钥交换算法 https://www.likecs.com/default/index/show?id=124371
[13] ECC椭圆曲线加密算法 https://zhuanlan.zhihu.com/p/66794410

你可能感兴趣的:(常用加密算法分析和实现)