你好,我是周志明。这节课,我们来讨论在信息系统中,一个一直非常受人关注的安全性议题:保密。
保密是加密和解密的统称,意思就是以某种特殊的算法改变原有的信息数据,使得未授权的用户即使获得了已加密的信息,但因为不知道解密的方法,或者就算知晓解密的算法、但缺少解密所需的必要信息,所以仍然无法了解数据的真实内容。
那么,根据需要保密信息所处的不同环节,我们可以将其划分为“信息在客户端时的保密”“信息在传输时的保密”和“信息在服务端时的保密”三类,或者也可以进一步概括为“端的保密”和“链路的保密”两类。
这里,我们先把最复杂、最有效,但是又最早就有了标准解决方案的“传输”环节单独拿出来,放到后面两讲中展开探讨。在今天的这节课当中,我们只讨论两个端的环节,即在客户端和服务端中的信息保密问题。
好,首先我们要知道,保密是有成本的,追求越高的安全等级,我们就要付出越多的工作量与算力消耗。就连国家保密法都会把秘密信息划分为秘密、机密、绝密三级来区别对待,可见即使是信息安全,也应该有所取舍。
那么接下来,我就以用户登录为例,给你列举几种不同强度的保密手段,看看它们的防御关注点与弱点分别都是什么。这里你需要注意的是,以下提及到的不同保密手段,并不一定就是正确的做法,只是为了强调保密手段是有成本、有不同的强度的。
如果密码本身比较复杂,那么一次简单的哈希摘要就至少可以保证,即使在传输过程中有信息泄露,也不会被逆推出原信息;即使密码在一个系统中泄露了,也不至于威胁到其他系统的使用。但这种处理不能防止弱密码被彩虹表攻击所破解。
盐值可以替弱密码建立一道防御屏障,在一定程度上可以防御已有的彩虹表攻击。但它并不能阻止加密结果被监听、窃取后,攻击者直接发送加密结果给服务端进行冒认。
如果每次向服务端传输时,密码都掺入了动态的盐值,让每次加密的结果都不一样,那么即使传输给服务端的加密结果被窃取了,攻击者也不能冒用来进行另一次调用。不过,尽管在双方通讯均可能泄露的前提下,协商出只有通讯双方才知道的保密信息是完全可行的(后面两讲介绍“传输安全层”时会提到),但这样协商出盐值的过程将变得极为复杂,而且每次协商只能保护一次操作,因而也很难阻止攻击者对其他服务的重放攻击。
我们可以给服务加入动态令牌,这样在网关或其他流量公共位置建立校验逻辑,服务端愿意付出在集群中分发令牌信息等代价的前提下,就可以做到防止重放攻击。但这种手段的弱点是,依然不能抵御传输过程中被嗅探而泄露信息的问题。
启用HTTPS可以防御链路上的恶意嗅探,也能在通讯层面解决重放攻击的问题。但是它依然有因客户端被攻破而产生伪造根证书的风险、因服务端被攻破产生证书泄露被中间人冒认的风险、因CRL更新不及时或者OCSP Soft-fail产生吊销证书被冒用的风险,以及因TLS的版本过低或密码学套件选用不当产生加密强度不足的风险。
为了抵御前面提到的这种种风险,我们还要进一步提升保密强度。比如说,银行会使用独立于客户端的存储证书的物理设备(俗称的U盾),来避免根证书被客户端中的恶意程序窃取伪造;当大型网站涉及到账号、金钱等操作时,会使用双重验证开辟出一条独立于网络的信息通道(如手机验证码、电子邮件),来显著提高冒认的难度;甚至一些关键企业(如国家电网)或机构(如军事机构),会专门建设遍布全国各地的、与公网物理隔离的专用内部网络,来保障通讯安全。
现在,通过了解以上这些逐步升级的保密措施,你应该能对“更高的安全强度同时也意味着要付出更多的代价”,有更加具体的理解了,并不是任何一个网站、系统、服务都需要无限拔高的安全性。
也许这个时候,你还会好奇另一个问题:安全的强度有尽头吗?存不存在某种绝对安全的保密方式?
答案可能会出乎你的意料,确实是有的。信息论之父香农就严格证明了一次性密码(One Time Password)的绝对安全性。
但是使用一次性密码必须有个前提,就是我们已经提前安全地把密码或密码列表传达给了对方。比如说,你给朋友人肉送去一本存储了完全随机密码的密码本,然后每次使用其中一条密码来进行加密通讯,用完一条丢弃一条。这样理论上可以做到绝对的安全,但显然这种绝对安全对于互联网来说没有任何的可行性。
所以下面,我们就来看一下在互联网中,信息在客户端的加密是否有必要和有价值。
其实,客户端在用户登录、注册一类场景里是否需要对密码进行加密,这个问题一直存有争议。而我的观点很明确:为了保证信息不被黑客窃取而去做客户端加密,其实没有太大意义,对绝大多数的信息系统来说,启用HTTPS可以说是唯一的实际可行的方案。但是!为了保证密码不在服务端被滥用,而在客户端就开始加密的做法,还是很有意义的。
现在,大网站被拖库的事情层出不穷,密码明文被写入数据库、被输出到日志中之类的事情也屡见不鲜。所以在做系统设计的时候,我们就应该把明文密码这种东西当成是最烫手的山芋来看待,越早消灭掉越好。毕竟把一个潜在的炸弹从客户端运到服务端,对绝大多数系统来说都没有必要。
那我为什么会说,客户端加密对防御泄密没有意义呢?原因是网络通讯并不是由发送方和接收方点对点进行的,客户端无法决定用户送出的信息能不能到达服务端,或者会经过怎样的路径到达服务端,所以在传输链路必定是不安全的前提下,无论客户端做什么防御措施,最终都会沦为“马其诺防线”。
此外,前面我还多次提到过中间人攻击(即攻击者),它是指通过劫持掉客户端到服务端之间的某个节点,包括但不限于代理(通过HTTP代理返回赝品)、路由器(通过路由导向赝品)、DNS服务(直接将机器的DNS查询结果替换为赝品地址)等,来给你访问的页面或服务注入恶意的代码。极端情况下,甚至可能把你要访问的服务或页面整个给取代掉,此时不管你在页面上设计了多么精巧严密的加密措施,也都不会有保护作用。而攻击者只需劫持路由器,或者是在局域网内的其他机器上释放ARP病毒,便有可能做到这一点。
额外知识:中间人攻击(Man-in-the-Middle Attack,MitM)-
在消息发出方和接收方之间拦截双方通讯。我们用写信来做个类比:你给朋友写了一封信,而邮递员可以拆开看你寄出去的信,甚至把信的内容改掉,然后重新封起来,再寄出去给你的朋友。朋友收到信之后给你回信,邮递员又可以拆开看,看完随便改,改完封好再送到你手上。你全程都不知道自己寄出去的信和收到的信都经过邮递员这个“中间人”转手和处理。换句话说,对于你和你朋友来讲,邮递员这个“中间人”角色是不可见的。
当然了,对于“不应把明文传递到服务端”的这个观点,很多人也会有一些不同的意见。比如其中一种保存明文密码的理由是为了便于客户端做动态加盐,因为这样需要服务端先存储明文,或者是存储某种盐值/密钥固定的加密结果,才能每次用新的盐值重新加密,然后与客户端传上来的加密结果进行比对。
而对此我的看法是,这种每次从服务端请求动态盐值,在客户端加盐传输的做法通常都得不偿失,因为客户端无论是否动态加盐,都不可能代替HTTPS。真正防御性的密码加密存储确实应该在服务端中进行,但这是为了防御服务端被攻破而批量泄露密码的风险,并不是为了增强传输过程的安全性。
那么,在服务端是如何处理信息的保密问题的呢?
接下来,我就以Fenix’s Bookstore中的真实代码为例,给你介绍一下针对一个普通安全强度的信息系统,密码要如何从客户端传输到服务端,然后存储进数据库。
这里的“普通安全强度”的意思是,在具有一定保密安全性的同时,避免消耗过多的运算资源,这样验证起来也相对便捷。毕竟对多数信息系统来说,只要配合一定的密码规则约束,比如密码要求长度、特殊字符等等,再配合HTTPS传输,就已经足够防御大多数风险了。即使是用户采用了弱密码、客户端通讯被监听、服务端被拖库、泄露了存储的密文和盐值等问题同时发生,也能够最大限度地避免用户明文密码被逆推出来。
好,下面我们就先来看看,在Fenix’s Bookstore中密码是如何创建出来的。
首先,用户在客户端注册,输入明文密码:123456。
password = 123456
然后,客户端对用户密码进行简单哈希摘要,我们可选的算法有MD2/4/5、SHA1/256/512、BCrypt、PBKDF1/2,等等。这里为了突出“简单”的哈希摘要,我故意没有排除掉MD系这些已经有了高效碰撞手段的算法。
client_hash = MD5(password) // e10adc3949ba59abbe56e057f20f883e
接着,为了防御彩虹表攻击,我们应进行加盐处理,客户端加盐只需要取固定的字符串即可,如果实在不安心,可以使用伪动态的盐值(“伪动态”是指服务端不需要额外通讯就可以得到的信息,比如由日期或用户名等自然变化的内容,加上固定字符串构成)。
client_hash = MD5(MD5(password) + salt) // SALT = $2a$10$o5L.dWYEjZjaejOmN3x4Qu
现在,我们假设攻击者截获了客户端发出的信息,得到了摘要结果和采用的盐值,那攻击者就可以枚举遍历所有8位字符以内(“8位”只是举个例子,反正就是指弱密码,你如果拿1024位随机字符当密码用,加不加盐,彩虹表都跟你没什么关系)的弱密码,然后对每个密码再加盐计算,就得到了一个针对固定盐值的对照彩虹表。
所以为了应对这种暴力破解,我并不提倡在盐值上做动态化,更理想的方式是引入慢哈希函数来解决。
慢哈希函数是指这个函数的执行时间是可以调节的哈希函数,它通常是以控制调用次数来实现的。BCrypt算法就是一种典型的慢哈希函数,它在做哈希计算时,接受盐值Salt和执行成本Cost两个参数(代码层面Cost一般是混入在Salt中,比如上面例子中的Salt就是混入了10轮运算的盐值,10轮的意思是2的10次方哈希,Cost参数是放在指数上的,最大取值就31)。
那么,如果我们控制BCrypt的执行时间,大概是0.1秒完成一次哈希计算的话,按照1秒生成10个哈希值的速度,要算完所有的10位大小写字母和数字组成的弱密码,就大概需要P(62,10)/(360024365)/0.1=1,237,204,169年的时间。
client_hash = BCrypt(MD5(password) + salt) // MFfTW3uNI4eqhwDkG7HP9p2mzEUu/r2
好,接下来,我们要做的就只是防御服务端被拖库后,针对固定盐值的批量彩虹表攻击。具体做法是为每一个密码(指客户端传来的哈希值)产生一个随机的盐值。我建议采用“密码学安全伪随机数生成器”(Cryptographically Secure Pseudo-Random Number Generator,CSPRNG),来生成一个长度与哈希值相等的随机字符串。
对于Java语言来说,从Java SE 7开始,就提供了java.security.SecureRandom类,用于支持CSPRNG字符串生成。
SecureRandom random = new SecureRandom();
byte server_salt[] = new byte[36];
random.nextBytes(server_salt); // tq2pdxrblkbgp8vt8kbdpmzdh1w8bex
好,我们继续进行这个密码的创建过程。我们把动态盐值混入客户端传来的哈希值,再做一次哈希,产生出最终的密文,并和上一步随机生成的盐值一起写入到同一条数据库记录中(由于慢哈希算法会占用大量的处理器资源,所以我并不推荐在服务端中采用)。
不过,如果你在学习课程后面的实战模块时,阅读了Fenix’s Bookstore的源码,就会发现这步依然采用了Spring Security 5中的BcryptPasswordEncoder。但是请注意,它默认构造函数中的Cost参数值为-1,经转换后实际只进行了2的10次方=1024次计算,所以不会对服务端造成额外的压力。
另外你还可以看到,代码中并没有显式地传入CSPRNG生成的盐值,这是因为BCryptPasswordEncoder本身就会自动调用CSPRNG产生盐值,并将该盐值输出在结果的前32位之中,所以也不需要专门在数据库中设计存储盐值字段。
这个过程我们用伪代码来表示一下:
server_hash = SHA256(client_hash + server_salt); // 55b4b5815c216cf80599990e781cd8974a1e384d49fbde7776d096e1dd436f67
DB.save(server_hash, server_salt);
到这里,你会发现这个加密存储的过程其实相对比较复杂,但是运算压力最大的过程(慢哈希)是在客户端完成的,对服务端的压力很小,也不用怕因网络通讯被截获而导致明文密码泄露的问题。
OK,等密码存储完之后,后面验证的过程就跟加密的操作是类似的,我们简单了解下这个步骤就可以了:
首先,在客户端,用户在登录页面中输入密码明文:123456,经过与注册相同的加密过程,向服务端传输加密后的结果。
authentication_hash = MFfTW3uNI4eqhwDkG7HP9p2mzEUu/r2
然后,在服务端,接收到客户端传输上来的哈希值,从数据库中取出登录用户对应的密文和盐值,采用相同的哈希算法,针对客户端传来的哈希值、服务端存储的盐值计算摘要结果。
result = SHA256(authentication_hash + server_salt); // 55b4b5815c216cf80599990e781cd8974a1e384d49fbde7776d096e1dd436f67
最后,比较上一步的结果和数据库储存的哈希值是否相同,如果相同就说明密码正确,反之密码错误。
authentication = compare(result, server_hash) // yes
这节课我们其实讨论了两个观点:
另外,针对“如何取得相对安全与良好性能之间平衡”这个问题,也是你在进行架构设计时必须权衡取舍的。
这节课,我们讨论到了客户端对敏感信息加密后,传输是否有意义的话题。请说说你对这个问题的看法吧。
欢迎在留言区分享你的见解。如果你觉得有收获,也欢迎你把今天的内容分享给更多的朋友。
好,感谢你的阅读,我们下一讲再见。