Internet最初出现时,被预期成一个友好、和谐的信息交换网络,因此HTTP协议中最初的身份验证是使用Base Auth那类几乎是透明的鉴权方式。然而工程师们显然还是太单纯了,有利益之处,就有人性的最黑暗面,非法获取、篡改信息从来没有停止过。
不过好消息是人类的智慧是无穷的,只要有问题,就会衍生出各种解决方案。“密码技术”,并不仅仅应用于加密信息让黑客无法获取原始信息,还用于身份鉴别、防否认、防篡改。密码技术就是用人类的无限智慧去对抗人性的无限黑暗。
一、加密算法与密钥
最早的加密方式之一是古罗马的凯撒密码,是通过将密文的字母按照字母表向后平移N位,来让敌军即使截获信件也不知道在表达什么意思。
凯撒密码的加密方式描述为:将原文按照字母表顺序,向后平移
N
位。其中N
是一个可变量,敌军在不知道N
的值的情况下,是无法破译密文的,即使他们知道凯撒的加密方式就是将原文平移。将原文按字母表顺序平移
是加密算法,只有算法被敌军知道他们也是无法解密的,N
是密钥,需要将N
带入加密算法才能正确解密。因此,保密的核心,不在算法,而在密钥。相反,算法应该公之于众,鼓励大家破解,只有无数精英都难以破解的算法,才是相对安全的算法。如果算法仅仅自己保密,想依靠对加密算法的保密,来确保安全性的行为称为“隐蔽式安全”,是愚蠢且不可靠的,因为未经检验的算法,健壮性不够,在专业破译人员手中很容易破解,但是自己却觉得算法从未公开过,一定非常安全。事实上,这近似于掩耳盗铃。黑客可以通过各种方式获取、破解你的算法,其中最弱的一环,就是人。因为凯撒密码的加密算法过于简单,因此即使不知道密钥,只需要试遍25次,就一定能暴力破解出密钥,解出正确密文。不过现代的加密算法已经越来越完善和复杂,建立在严密的数学基础之上,例如目前主流的非对称加密RSA算法,只要保管好密钥,可以考虑为现实维度的绝对安全。
二、对称加密与密钥配送问题
对称加密是指加密与解密使用同一个密钥,之前说的凯撒密码,就是典型的对称加密,用于加密的变量N
,也是用于解密的变量。
现在流行的对称加密算法有DES、三重DES、AES等,具体算法就不在这里说了,但是都非常复杂,已经不是凯撒密码能比的了,而且安全性也非常高。但是问题来了,A与B要通讯,就必须两方都知道密钥,否则无法完成加解密,密钥一定不能让第三者知道,否则一切的加密都是没有意义的,该怎么保证密钥的保密性呢?
当面交接密钥是比较稳妥的方式,但是在互联网的加密中就不可行了,如果为了交换密钥,需要双方面对面,那么互联网的意义就不存在了,因此需要寻找一种远程安全交换密钥的方式。但是,只要信息在互联网中传输,就存在被窃听的风险,所以,对称加密是无法解决密钥配送问题的。
转机出现在1976年,斯坦福大学的两个教授Diffie和Hellman研究出了解决密钥交换的数学模型:
首先,Alice需要生成两个超大的素数g、p,并且g要是p的primitive root module p,这里并不用理解是什么意思,只需要知道g、p是满足某一条件的超大素数。Alice生成一个随机整数a,并做g的a次方对p取模的运算,计算出A,最后将g、p、A通过网络共享给Bob,Bob生成一个随机整数b,并做g的b次方对p取模的运算求出B,然后将B回传给Alice,Alice做B的a次方对p取模的操作求出密钥,同时Bob通过A的b次方对p取模,也能求得同样的值,也就是密钥。整个过程,网络中只交换了大素数g、p以及求模值A、B,两个随机数a、b一直处于保密状态,虽然理论上可以通过g/p/A/B这四个值求出a、b,最终求出密钥,但是这可能需要超级长的时间,g、p越大,所需时间越长,可能是上百年,这就是数学中的离散对数难题。如果一个密钥需要上百年才能破解,在现实维度上,可以考虑为绝对安全。
三、非对称加密
在Diffie-Hellman算法提出的一年后,也就是1977年,MIT的三个教授提出了非对称加密算法,用他们名字的首字母命名——RSA。非对称加密又称公钥加密,这是密码学史上最重大的里程碑之一,奠定了现代密码学的基础,如今互联网中的加解密几乎都是使用公钥加密。
非对称加密,顾名思义,加密和解密不是使用同一个密钥,而是有两把密钥,一把负责加密,一把负责解密,我们计算出A、B两把密钥后,将一把作为私钥,自己保存,用于解密,另一把作为公钥,可以随意分配给任何人,用于加密。
甲要向乙发送消息,乙只需把自己的公钥传输给甲,然后发送方甲将明文用接收方乙的公钥加密后,传输给乙,乙收到密文,用自己的私钥解密。
有了公钥加密后,就不存在密钥配送问题了,因为用于解密的私钥,我们不需要在网络上传输,只需要自己保存就好(乙保留自己的私钥),公钥随意在网络上传输,因为公钥只能用于加密,不能用于解密:
如图,RSA中明文对自己做E次方的乘法,然后再对N求模数,就是密文,解密需要将密文做D次方的乘法,再对N求模数,就是明文,其中(E,N)组成了加密密钥,(D,N)组成了解密密钥。至于E、D、N三个值是如何求出,原理也是类似于Diffie-Hellman算法,即利用理论可解,但现实难解的问题去计算:首先准备两个超大的质数p、q,大到起码是上百位的十进制数字,然后利用p、q去计算N、E、D,其中N是p、q的乘积,只知道E、N不知道p、q,是无法求出D的,既然知道乘积N,又怎么不能分解出p、q呢?理论上是可以的,但是目前还没有找到一个高效的质因数分解的方法,因此计算非常困难,越长的RSA密钥,越难分解,几十上百年分解出密钥,就不存在太大意义了。
从上图中的公式中,可以看出,如果只有公钥(E,N),没有私钥中的D是无法将密文解成明文的,所以用于加密的公钥可以随意在网络中传播而不必担心安全问题,而私钥是必须保存好的,一旦私钥泄露,所有基于这对秘钥的通讯都是不安全的通讯。
我们可以利用OpenSSL库轻松生成RSA密钥:
- 先来生成一个1024bit的RSA私钥:
$ openssl genrsa -out private.pem 1024
$ cat private.pem
除了开头和结尾有几个单词,中间是一串不可读的base64编码密钥。使用 -text 参数可以打印出人大概能读的私钥文件:
$ openssl rsa -text -in private.pem
可见私钥文件有RSA密钥对的所有元素。
目前大多数私钥文件都不仅仅是包含私钥,还包含了计算RSA密钥对所需要的所有东西,如下:
1. 模数 N
2. 私有指数 D
3. 公开指数 E (基本上都是3或者65537)
4. 因子 p 、q
5. p - 1 和 q - 1的最小公倍数 L
6. q - 1 mod p 的值(系数)
只要有以上的数据,可以轻松计算整个密钥对,也就是说,是可以通过私钥文件计算出公钥的。请注意是私钥文件而不是私钥,因为私钥仅仅由(D,N)组成,是无法推算出p、q的,而私钥文件中常常携带了完整信息,所以,私钥无法推算公钥,但是(大多数)私钥文件是可以的。 - 利用私钥文件,生成公钥:
$ openssl rsa -in private.pem -out public.pem -outform PEM -pubout
- 下面我们可以用这对密钥来加解密了,先写一点有意义的内容:
$ echo 'too many secrets' > file.txt
- 我们用公钥加密它:
$ openssl rsautl -encrypt -inkey public.pem -pubin -in file.txt -out file.ssl
- 用私钥解密它:
$ openssl rsautl -decrypt -inkey private.pem -in file.ssl -out decrypted.txt
由于RSA是利用分解质因数的难题来保证密钥不被破解的,那么越大的p、q,也就是越长的密钥,越难被破解,目前在投入非常大量的计算资源的情况下,已经能够分解长度为1024比特的整数了,对此,NIST SP800-57给出了如下方针:
- 1024比特的RSA不应被用于新的用途
- 2048比特的RSA可在2030年之前被用于新的用途
- 4096比特的RSA在2031年之后仍可被用于新的用途
RSA巧妙地避开了密钥配送的问题,而缺点是其加密所需消耗的时间是对称加密的数百倍,如果用它来加密我们日常所传输的大量数据,效率会非常之低。因此在一般的通讯中,我们往往是将公钥加密与对称加密混合使用,使用公钥加密配送对称密钥,其后的通讯中使用对称密钥加解密信息。
尽管公钥加密解决了密钥配送问题,但针对公钥密码可以进行中间人攻击,要防御这种攻击,就要解决认证为题,也就是:“和我通讯的这个人,到底是不是我想要通讯的那个人”,后面会讲到解决认证问题的技术。
四、单向散列(Hash)
虽然利用强大的加密算法可以防止真实信息被获取,但是获取信息绝对不是唯一的攻击目的,有的攻击者并不需要获取你真实的信息,他们的目的是——篡改。
想象你存在计算机中的合同,在某个深夜,被黑客篡改了条款,你却无从得知,因此,衍生出了一种需求:需要一种方式来判断信息是否被篡改。
单向散列函数解决了这个问题,它具有指纹功能,对于给定的任意长度的比特,都计算出等长的散列值。
单向散列函数可以理解为一种算法规范、协议,它并不是一种单纯的算法,满足单向散列函数规范的算法,都称为散列算法,比如MD4、MD5、SHA1等。单向散列函数都满足一种规范:
输入任意长度的比特串,都为其生成固定长度的散列值,并且其值不可逆运算出原串
。单向散列函数一般是将原串分组、填充后,进行取模、加法等多轮运算,最后挤出定长Hash值。
单向散列有以下几个非常重要的特点:
- 正向快速: 即给定任意原串,都能利用现实有限的资源在很快的时间计算出Hash值。
- 无法逆向: 因为Hash值是原串的所有比特糅合运算的结果,我们在只知道Hash值的情况下是无法推测出原串的,哪怕有足够强大的运算能力去遍历。例如我随机选定两个整数,只告诉你A、B两数进行加法运算结果为20,你也绝对不可能推测出A、B具体是哪个值,Hash也是同理。
- 输入敏感: 每当修改原串1比特的数据,输出的Hash值都会产生变化。
- 抗碰撞性: 抗碰撞性分为弱抗碰撞性与强抗碰撞性,弱抗碰撞性是指,给定一个原串,无法找出Hash值相同的其他原串,强抗碰撞性是指,不给任何限制,不能找出Hash值相同的两个不同原串。因此。
基于这些特点,单向散列非常适合作为验证数据原始性的工具,当我们在网上下载软件时,大多数网站都会在后面附加MD5、SHA1值,当你下载软件后,可以通过验证Hash值去验证软件是否有被修改,比如下载损坏、植入病毒。
目前,MD4、MD5、SHA1的抗碰撞性均在2004、2005先后被王小云教授击破,对于一个给定原串,已经能够产生与其Hash值相同的另一原串,因此建议转向相对更安全的SHA-3等算法。
Example-两个MD5碰撞的字节串:
// 十六进制表示的字节串,先转回为字节
const buffer1 = Buffer.from('0e306561559aa787d00bc6f70bbdfe3404cf03659e704f8534c00ffb659c4c8740cc942feb2da115a3f4155cbb8607497386656d7d1f34a42059d78f5a8dd1ef','hex'));
const buffer2 = Buffer.from('0e306561559aa787d00bc6f70bbdfe3404cf03659e704f8534c00ffb659c4c8740cc942feb2da115a3f4155cbb8607497386656d7d1f34a42059d78f5a8dd1ef','hex'));
let hash = crypto.createHash('md5');
hash.update(buffer1).digest('hex'); // cee9a457e790cf20d4bdaa6d69f01e41
hash = crypto.createHash('md5');
hash.update(buffer2).digest('hex'); // cee9a457e790cf20d4bdaa6d69f01e41
消息认证码(MAC)
我们从银行汇款说起:
客户A199申请了跨行转账后,A银行发消息给B银行:
从账户A199向账户B778汇款100元
金融是很严肃的事,稍有不慎,就是损失巨款,B银行怎么确定这是A银行发来的,而不是黑客伪造的呢?这就需要消息的认证技术。
消息认证码(Message Authentication Code),简称MAC,是一种确认完整性并进行认证的技术,MAC接收任意长度的消息和一个密钥作为输入,输出固定长度的数据,这个数据称为MAC值。这和单向散列函数非常像,同时我们目前使用的MAC技术主要是HMAC,H是Hash-Based,是基于单向散列的MAC技术,任何高强度的散列函数都可以被用于HMAC,例如SHA-1、SHA-256所构造的HMAC分别称为HMAC-SHA1、HMAC-SHA256。
简单地来说,HMAC就是糅合了密钥的散列函数,因此,计算出的MAC值,是带密钥特征的Hash值,只要B银行持有A银行的密钥(在对称加密中,双方一定持有互相的密钥,因为是同一把),A在发送消息时,使用HMAC计算好这条消息的MAC值一起发送给B,B只要将消息与A的密钥同时输入HMAC,将自己计算出的MAC值,与A传送过来的MAC值作对比,就知道这条消息是不是同样持有密钥的A所发送的消息。
数字签名
即使有了HMAC,我们同样会面临问题,那就是虽然可以验证是否是密钥持有者发来的信息,但由于A、B都持有同一把密钥,那么就存在A可能会否认或者B伪装成A的情况,毕竟双方都持有密钥,双方都能生成同样的消息同样的HMAC。
那么有没有一种方法,能够使B仅仅能验证A的HMAC,却不能产生A的HMAC?目前通过对称密钥的方式,明显是不可以的。公钥加密在这里就派上了大用场,根据公钥加密的原理,私钥和公钥的加解密是可以互换的,也就是使用公钥加密,可以使用私钥解密,如果使用私钥加密,那么公钥则可以解密。那么我们可以用A银行的私钥生成签名,B银行持有A银行的公钥用于验证签名。
签名可能不大好理解,其实就是加密:
A银行在发送消息前计算消息的Hash值,再将Hash值用自己的私钥加密生成数字签名,将数字签名附加在消息正文的后面,B银行收到消息后,计算消息正文的Hash值,再将A传输过来的数字签名用A的公钥解密为Hash值,对比两个Hash值,如果相同,则代表消息正文既未被篡改同时又是A发来的消息,既保证了消息的完整性又做了身份认证。
数字签名为什么要对Hash值签名,而不是消息正文呢?因为消息正文的信息量往往比较大,自然是对Hash值签名,效率比较高。
应对重放攻击:
虽然通过HMAC以及数字签名,黑客无法冒充A银行向B银行发送消息,但是他可以去A银行申请让B银行汇款100元至自己的账户,然后中途窃听这条请求,保存请求,并重复发送1万次,B银行就会转账共100万元至黑客的账号。整个过程,黑客无需解密、篡改等任何手段,做的仅仅是将相同的请求发送一万次,这称为重放攻击。所以仅仅使用HMAC和数字签名却不增加随机数、递增消息版本号等其他补充性的安全手段,很容易遭受应对重放攻击。
数字证书
在前面的公钥加密中我们有说到,公钥加密无法防范中间人攻击,甚至可以说,有我们公钥的人,就能冒充另一个持有公钥的人,我们没法判断他是否是他声称的那个人。
A、B两方通讯时,需要先交换密钥,A将自己的公钥传送给B,B将自己的公钥传送给A,此时如果有黑客在中间截断他们的通讯,成为他们的中间节点,则黑客可以冒充A向B发送自己的公钥,再冒充B向A发送自己的公钥,他们通讯时都使用黑客的公钥加密,黑客均可解密。
B银行怎么能够验证自己所获取的公钥,是不是A银行的呢?我们的解决方式是引入数字证书,数字证书是有认证机构(Certification Authority,CA)签发的文件,其中带有申请签发者的公钥以及签发机构的签名,数字证书的格式普遍采用的是X.509V3国际标准,一个标准的X.509数字证书包含以下一些内容:
证书的版本信息;
证书的序列号,每个证书都有一个唯一的证书序列号;
证书所使用的签名算法;
证书的发行机构名称,命名规则一般采用X.500格式;
证书的有效期,通用的证书一般采用UTC时间格式,它的计时范围为1950-2049;
证书所有人的名称,命名规则一般采用X.500格式;
证书所有人的公开密钥;
证书发行者对证书的签名。
B银行拿到证书后,可以向证书发行机构(CA)验证此证书的真实性,一旦符合,则代表此公钥就是A银行的。不过也有一个前提,那就是这个CA值得信任,CA有很多,每个人都可以建立自己的CA,因此如果你拿到的是黑客自建CA签发的证书,也是没有意义的,所以不要随意信任所有CA,应该选择性地信任,否则数字证书也是形同虚设。
我们使用的HTTPS就是在HTTP之下放置了一层安全套接层,其中有使用公钥加密,因此如果服务器要开始HTTPS,是需要提供自己的数字证书的,一般企业都会缴费向一些大的CA机构申请证书,大致流程如下: