保密

保密(Confidentiality)

系统如何保证敏感数据无法被包括系统管理员在内的内外部人员所窃取、滥用?

保密是加密和解密的统称,是以某种特殊的算法改变原有的信息数据,使得未授权的用户即使获得了已加密的信息,但因不知解密的方法,仍然无法了解信息的内容。

保密这个话题,按照需要保密信息所处的环节不同,可以划分为“信息在客户端时的保密”、“信息在传输时的保密”和“信息在服务端时的保密”,又或者进一步概括为“端的保密”和“链路的保密”。我们把最复杂、最有效,但却又早就有了标准解决方案的“传输环节”单独提取出来,放到下一个小节去讨论。在本小节中,只讨论两个端的环节,即在客户端和服务端中的信息保密问题,谈一下笔者的几个观点。

安全的强度

首先来说一下“安全”的程度问题,保密的安全与否不应该被视为一个离散的二元选项,不是仅有“安全”或者“不安全”的差别,而是随着你的应用所要求的保密程度不同,应该有着不同的安全强度与之对应。这里面说的意思与很多口号中强调的“安全无小事”、“99%安全加1%的漏洞等于零”并不是一码事。我通过以下这些逐步提升的攻击手段和应对措施来解释“安全强度”是意味着什么:

  1. 给密码做最简单的MD5,如果你的密码本身比较复杂,那一次简单的MD5至少可以保证密码不会被逆推出明文,密码在一个系统中泄漏了不至于威胁到其他系统的使用,但这不能阻止弱密码被彩虹表攻击所逆推。
  2. 给密码加上固定的盐值,如果给密码加上盐值,可以替弱密码建立一道防御屏障,一定程度上防御已有的彩虹表攻击,但不能阻止加密结果被窃取后(譬如在链路上被抓包了),攻击者直接发送加密结果给服务端进行冒认。
  3. 给密码加上动态的盐值,如果每次密码向服务端传输时都加入了动态的盐值,让每次加密的结果都不同,那即时传输给服务端的加密密码被窃取了,也无法用来冒认,但这只能保护登录这一个操作,无法阻止对其他功能的重放攻击
  4. 采用动态令牌与服务端的逻辑配合,可以做到防止重放攻击,依然无法抵御传输过程中被嗅探而泄漏信息的问题(如前面说的在链路上被抓包了)。
  5. 启用HTTPS(且恰当选择支持的密码学算法、保护好证书),可以防御链路上的恶意嗅探,也在协议层面解决了重放攻击的问题,但它依然存在有被攻击的可能性,譬如受到证书攻击导致握手失败。
  6. ……

到了第5点,只要做法规范,已经可以抵御绝大多数系统性的安全风险了,但也意味着你需要为它付出一些代价(包括加解密的算力,也包括购买证书的费用)。而安全的强度还可以用不同途径继续往上提升,如许多网站会使用手机验证码开辟另一条独立的信息传输渠道来保障安全、如银行会使用有专门物理存储的证书(就是俗称的U盾)来保障安全、如国家电网那样建设遍布全国各地的与公网物理隔离的专用网络来保障安全,等等。显然追求安全强度同时也意味着付出更多代价,肯定不是任何一个网站、系统都需要无限拔高的安全强度。

另一个问题是安全强度有尽头吗?存不存在某种绝对安全的保密方式?答案可能出乎多数人的意料,确实是有的。信息论之父香农严格证明了一次性密码(One Time Password)的绝对安全性。但是使用一次性密码必须有个前提,就是先把安全的把密码(密码列表)传达给对方。譬如,给你的朋友(人肉)送去一本存储了完全随机密码的密码本,然后每次使用其中一条密码来进行加密通讯,用完一条丢弃一条,理论上这是绝对安全的,但显然这对于公众互联网是没有任何的可执行性。

客户端加密

客户端在用户登录、注册一类场景里是否需要对密码进行加密,这个问题一直存有争议。我的观点是,为了保证密码不被黑客窃取而做客户端加密没有太多意义,上HTTPS可以说是唯一的普通系统实际可行的解决方案。但是!为了保证密码不在服务端被滥用,在客户端就开始加密是很有意义的。大网站被拖库的事情层出不穷,密码明文被写入数据库、被输出到日志中之类的事情屡见不鲜,做系统设计时就应该把明文密码这种东西当成是最烫手的山芋来看待,越早消灭掉越好。

关于第一个“没有太多意义”,有人不理解为什么为什么客户端加密对防御黑客会没有意义,我举个例子,在极端情况下,客户端可能被整个架空掉,这样上面无论做了什么防御措施都成“马其诺防线”了。典型的就是之前已经提到的中间人攻击,它可以通过劫持掉了客户端到服务端之间的某个节点,包括但不限于代理(通过HTTP代理返回赝品)、路由器(通过路由导向赝品)、DNS服务(直接将你机器的DNS查询结果替换为赝品地址)等等,把你要访问的登陆页面整个给替换掉(全替换掉工作量太大,一般不会去做,都是注入一段恶意的JavaScript代码到正版的页面里)。最简单的劫持路由器,在局域网内其他机器释放ARP病毒便有可能做到这一点。这部分内容属于链路安全,我们将在下一节来讲如何防御,这里附带Mozilla对中间人攻击的一段介绍以供参考。

额外知识:中间人攻击(Man-in-the-Middle Attack,MitM)

在消息发出方和接收方之间拦截双方通讯。用日常生活中的写信来类比的话:你给朋友写了一封信,邮递员可以把每一份你寄出去的信都拆开看,甚至把信的内容改掉,然后重新封起来,再寄出去给你的朋友。朋友收到信之后给你回信,邮递员又可以拆开看,看完随便改,改完封好再送到你手上。你全程都不知道自己寄出去的信和收到的信都经过邮递员这个“中间人”转手和处理——换句话说,对于你和你朋友来讲,邮递员这个“中间人”角色是不可见的。

关于第二个“很有意义”,居然也有人会抬杠。一种是说涉及到密码等敏感信息的都会由靠谱的人完成,或者就是他本人做的,所以不会出问题,我觉得这个就没什么必要反驳了,开心就好。另一种的观点是保存明文密码(把不含盐的哈希结果也作明文看待)的目的是为了便于客户端做动态盐值,因为这需要服务端存储了明文才能每次用新的盐值重新加密来与客户端传上来的加密结果进行比较。我的观点是每次从服务端请求盐值在客户端动态加盐往往会得不偿失,应在真正防御性的密码加密存储应该在服务端进行,因为客户端无轮是否动态加盐,都不能代替HTTPS。

密码存储和验证

下面以Fenix's Bookstore的实现为具体样例,介绍从密码如何从客户端传输到服务端,存储进数据库的全过程。在保障一定安全强度的同时,避免消耗过多的运算资源,验证起来也比较便捷。这套过程对于一般的系统,配合一定的约束(如密码要求长度、特殊字符等),再配合HTTPS传输应该是够用的。即使在客户采用了弱密码、客户端盐值泄漏(本来就不是保密的)、服务端被拖库泄漏了存储的密文和动态盐值这些问题同时发生,也没有用户明文密码被逆推出来的风险。

以下为密码创建的过程,

  1. 用户在客户端注册,输入明文密码:123456。

    password = 123456
    
  2. 客户端对用户密码进行简单Hash,可选的算法有MD2/4/5、SHA1/256/512、BCrypt、PBKDF1/2,等等。

    client_hash = MD5(password) // e10adc3949ba59abbe56e057f20f883e
    
  3. 为了防御彩虹表攻击,应加盐处理,客户端加盐可取固定的字符串,或者伪动态(日期、用户名加上固定字符串,反正就是服务端不需要额外通讯可以得到的值)的盐值。

    client_hash = MD5(MD5(password) + salt)  // SALT = $2a$10$o5L.dWYEjZjaejOmN3x4Qu
    
  4. 我们假设攻击者截获了传输,把哈希值和盐值都拿到了,那他可以枚举遍历所有10位数以内(10位数只是举个例子,反正就是弱密码,你拿1024位随机字符当密码用,加不加盐,彩虹表都跟你没任何关系)的弱密码,然后对每个密码再加盐计算,得到一个固定盐值的对照彩虹表。为了应对这种暴力破解,我们需要引入慢哈希函数来代替MD5来加强安全性。
    慢哈希函数是指这个函数执行时间(准确地说是运算次数)是可以调节的,BCrypt算法就是一种慢哈希函数,在做哈希时接收盐值salt和执行成本cost两个参数(代码层面cost一般是混入在salt中,譬如上面例子中的salt就是混入了10轮运算的盐值,10轮的意思是210次哈希,cost参数是放在指数上的,最大取值就31)。如果我们控制BCrypt的执行时间大概是0.1秒完成一次哈希计算的话,按照1秒生成10个哈希的速度,算完所有的10位大小写字母和数字组成的弱密码大概需要P(62,10)/(3600*24*365)/0.1=1,237,204,169年。

    client_hash = BCrypt(MD5(password) + salt)  // MFfTW3uNI4eqhwDkG7HP9p2mzEUu/r2
    
  5. 现在将哈希传输到服务端,链路这段安全在下一节去探讨。
    服务端接受到哈希值后,对每一个密码都动态生成一个随机盐值。比较主流的建议是采用“密码学安全伪随机数生成器(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
    
  6. 将盐混入客户端传来的哈希值,生成要存入数据库的密文,并将随机生成的盐值一并写入到同一条记录中。在服务端中就不建议采用慢哈希算法,对CPU占用率的影响较大,Spring Security 5的StandardPasswordEncoder提供了SHA256哈希算法的实现,就以此为例。

    server_hash = SHA256(client_hash + server_salt);  // 55b4b5815c216cf80599990e781cd8974a1e384d49fbde7776d096e1dd436f67
    DB.save(server_hash, server_salt);
    
  7. (可选)出于对SHA256安全性的不信任,Spring Security 5中StandardPasswordEncoder已被@Deprecated,介意或者想偷懒简化操作的话,推荐第5、6步采用BCryptPasswordEncoder来替代。尽管使用的是BCrypt算法,但默认构造函数中的cost是-1,即进行2-1=1次哈希计算,这并不会造成服务端压力。说可以偷懒是因为用BCryptPasswordEncoder的话就不需要专门传入盐值,它本身就会调用CSPRNG产生盐值,也不需要给数据库添加盐值字段了,在它生成密码的前32位自动存储了盐值。

以下为密码验证的过程:

  1. 客户端,用户在登陆页面中输入密码明文:123456,经过与注册相同的加密过程,向服务端传输加密后的结果。

    authentication_hash = MFfTW3uNI4eqhwDkG7HP9p2mzEUu/r2
    
  2. 服务端,接受到客户端传输上来的哈希值,从数据库中取出登陆用户对应的密文和盐值,采用服务端的哈希算法,对客户端传来的哈希值、服务端盐值计算出哈希结果。

    result = SHA256(authentication_hash + server_salt);  // 55b4b5815c216cf80599990e781cd8974a1e384d49fbde7776d096e1dd436f67
    
  3. 比较上一步的结果和数据库储存的哈希值是否相同,如果相同那么密码正确,反之密码错误。

    authentication = compare(result, server_hash) // yes
    
Kudos to Star
总字数: 3,775 字  最后更新: 9/1/2020, 6:21:30 PM