常用加密算法分析和实现

作者: 呼啸长风 | 来源:发表于2022-01-09 22:10 被阅读0次

    一、前言

    工作中有时候需要对数据进行加密,就笔者从事的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<r,满足gcd(e,r)=1, 也就是e和r互质。
      很多实现中, 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

    相关文章

      网友评论

        本文标题:常用加密算法分析和实现

        本文链接:https://www.haomeiwen.com/subject/vqykcrtx.html