Gone With the Wind

零门槛学习https--(5)常见的https攻击原理

上一篇:零门槛学习https–(4)https协议详解

看似毫无破绽的TLS协议,其实也存在着诸多漏洞,主要因为TLS协议太复杂了,复杂的东西必然会出现漏洞,下面我们来看看一些常见的攻击手段有哪些,以及一些攻击实例。

伪造证书

攻击原理

这是一种中间人攻击,就是伪造网站证书,虽然浏览器会提示访问了不安全的网址,但是大部分对https没什么了解的人基本都会选择忽略问题继续浏览,这是一种简单,成本低,且效果还可以的攻击方式。

防御方法

打开浏览器设置,如果证书不可信,那么就直接不能访问。这个其实也不能算是TLS的漏洞,只能说是浏览器为了做兼容而没有采取一些强制性措施,或许不久的将来,浏览器默认就会直接拒绝无效证书的连接。

POODLE

攻击原理

上一章看TLS协议的时候,我们看到,因为要处理兼容性问题,客户端和服务端不得不支持很多种加密套件和TLS或者SSL版本,由此,衍生一个问题就是:一些老久的加密套件或者SSL版本通常在现在的计算能力下安全性没那么高,或者早先的协议是有安全漏洞的,所以,针对SSL降级,还有加密降级攻击就成了一种主流的攻击方式。

POODLE的全称是Padding Oracle On Downgraded Legacy Encryption,由谷歌的工程师在2014年发现了这个问题,问题的主要原因在于分块加密。其原理是:

AES 分块加密在原文内容长度不满足时,会在原文后面填充一些内容,保证每个分块的长度一致,这就是Padding Oracle,也是攻击的关键,因为新版TLS不存在这个问题,所以我们需要对协议进行降级,而降级就是Downgraded Legacy Encryption,所以这也是这个漏洞名称的由来。

协议降级很简单,只要在客户端发送Client Hello消息的时候,把客户端支持的加密套件替换成SSL3.0,如果服务端支持,那么后续连接就会使用SSLV3来进行连接,具体的Padding Oracle攻击原理可以看一下这篇文章,写得非常详细。

防御方法

客户端禁用SSLV3协议,服务端也关闭SSLV3的连接。

FREAK

这个攻击方式可以说到一个很有意思的事情,目前基本主流的加密算法都是由美国开发的,90年代的时候,NSA为了能够监听世界上所有的网络通信,出台了对于软件出口规定,规定只有美国境内能够使用高强度的RSA加密,对于出口国外的软件,最高允许512密钥长度的RSA,高安全级别的加密算法被认为是战争武器而禁止对外出口。2000年之后,美国逐渐放宽了这个限制,目前美国任然有一些带有加密算法和函数的软硬件受到出口管制。

攻击原理

由于历史遗留原因,IE6,网景,甚至是手机上的安卓和iOS Safari 浏览器,都支持512密钥长度的RSA加密,这在2015年的时候才被法国工程师发现。据说在当时任然有38%的服务器还支持512长度的密钥,而且讽刺的是,美国一些政府部门网站也存在此漏洞。

攻击者在Client Hello的时候,把高强度的密钥加密算法都剔除掉,留下低强度的算法,如果服务端也支持低强度的加密算法,那么就会选择这一算法。虽然在90年代一般的PC还无法破解512长度密钥,但是目前个人已经完全有能力破解了。

防御方法

苹果已经在当时推出相应补丁,而安卓也在后续版本进行了升级。服务端应该禁止低强度加密算法。

HTTPS Strip

也称为SSLStrip,是一种基于社会学的攻击方法,也是中间人攻击的一种,和任何加密方式都无关,和SSL也无关,也是目前为止我成功实施的一种攻击方式。

攻击原理

通常,我们在浏览器访问一个网址的时候,会直接输入网址,而不会关注协议,比如我要访问天猫,那么我就会输入:tmall.com,而此时浏览器默认会使用http协议访问,因为浏览器并不知道服务端是否开启了https,所以,攻击者直接劫持http访问,然后自己去访问https://www.tmall.com,并把内容通过http协议返回给浏览器,浏览器成功收到数据,就直接开始渲染页面,浏览器端完全就是访问一个http站点。所以,这种攻击方式就叫做SSL剥离攻击。除了浏览器之外的场景,基本不会受到此攻击的影响。

防御方法

HSTS(HTTP Strict Transport Security),这是一种浏览器技术,在客户端访问站点的时候,如果检测到当前域名在HSTS列表中,那么直接使用https协议访问,也就不会被攻击者劫持了。那么如何加入HSTS列表呢?在浏览器第一次访问站点的时候,站点可以在http返回头里面来标示是否启用HSTS,还有过期时间。

这是第一次访问www.taobao.com的场景,http返回头有个:Strict-Transport-Security属性,过期时间是一年。

这是第二次访问www.taobao.com时候,因为已经在HSTS列表中,就直接是一个Internal Redirect,不会请求服务端。

其实你会发现,浏览器第一次访问网站的时候,也是有可能被劫持的,所以现在有些网址会内置在浏览器的HSTS列表中,防止第一次访问被劫持的情况发生。不久的将来,也许浏览器就默认使用https访问,这种攻击方式也就无法发挥作用了。

攻击实例

写个脚本就可以检测网站是否开启了HSTS,对于没有开启HSTS的网站都可以发动攻击。以下是我对tmall.com发起的一次http strip的攻击实例。

1
2
3
4
5
上图中看到访问tmall.com的跳转逻辑是:
tmall.com --302--> www.tmall.com --301--> https://www.tmall.com。

301还好,现代浏览器几乎会作为永久缓存,这种情况下如果不清除缓存,其实跟HSTS效果差不多。
302就坑了,基本是不会缓存的。

1
2
3
从上图中我们可以看到返回头中虽然包含了STS头,但是过期时间为0

也就是不开启HSTS,用了一个301跳到了https站点

下面我开始准备需要的东西,当然一个前提是你要能捕获流量:

  1. 为了方便我使用本机测试,可以建个DNS服务器,也可以使用更简单的host文件,我选择使用host文件,把tmall.comwww.tmall.com指向127.0.0.1。实际情况中,可以采用ARP欺骗等方式获取局域网流量。
  2. 在本机建立一个web server,具体的代码就不贴了,后面贴一些关键代码。

好,我们可以开始攻击了,具体步骤如下。

  1. 首先使用浏览器访问tmall.com,因为没有开启HSTS,浏览器开始用http方式请求tmall.com
  2. 这时候本次请求已经到我的中间人server,然后server使用IP方式访问真实的tmall.com,这里不能用域名,因为域名已经指向本机了。
  3. 拿到真实tmall.com的返回之后,添加如下代码:

    1
    body += '<script>alert( 'hello' )</script>';
  4. 返回给浏览器,此时,浏览器看起来是这个样子,弹出了hello,并且仔细看会发现https标示没有了,如果不弹出alert,天猫那个具有强烈视觉冲击感的页面,很容易让你忽略https标示没有了这件事。

  5. 再进一步看看,点击登陆按钮的跳转逻辑是:<a href="//:login.tmall.com">登陆</a>,没有写死协议,如果写死了协议,在返回内容的时候,手动替换为http即可。登录页同样没有开启HSTS,我们试试看能不能拿到账号密码。简单分析一下天猫登录页,真正账号密码的输入框采用的是login.taobao.com提供的iframe登录框,有二维码和手动输入账号密码两个选项,二维码无能为力了,看看能不能拿到手动输入账号密码时的内容。
  6. 看了看login.taobao.com,没有写死协议,并且发现同样没有开启HSTS,这就好办了,和tmall.com一样,拦截请求,并在返回内容添加如下代码,插入一个jQuery,并在在输入密码的时候获取输入事件:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    body += 
    "<script src=\"https://code.jquery.com/jquery-latest.js\"></script>" +
    "<script>" +
    "$( '#TPL_username_1' ).on( 'input', function (){" +
    "console.log( this.value );" +
    "} );" +
    "$( '#TPL_password_1' ).on( 'input', function (){" +
    "console.log( this.value );" +
    "} );" +
    "</script>";
  1. 运行脚本,看看效果:

    login.taobao.com竟然是GBK编码的,导致乱码了,这里我懒得转编码了,可以看到左上角其实浏览器已经显示了不安全三个字,同样估计很少有人会看到这几个字。我们尝试输入一下账号密码试试看:

    已经打印出账号密码,发个请求就能发送到我自己的服务器了,后面再跳回到https站点就神不知鬼不觉了。

  2. 总的来说,本次攻击的需要的条件其实是比较多的,首先因为http://www.tmall.com跳转到https://www.tmall.com是301跳转,而chrome等浏览器把301几乎是当做永久缓存来处理的,除非手动清理缓存,当然很多浏览器对301也不缓存,或者重启就没了。其次,默认的登陆方式是二维码登陆,二维码登陆就无法实施此次攻击了。不过基于天猫访问量巨大,符合条件的流量估计也不少,而且http://login.taobao.com跳往https站点是302,也就是不会缓存了,这个有安全隐患。如果某天天猫开启了HSTS,那么这类攻击就几乎无法生效了,毕竟这对天猫来说也就是一个配置项的事。

其他漏洞

  1. 完全信任证书漏洞。很多android APP采取了不校验证书的方式,导致中间人攻击非常容易,比如早先的亚马逊官方APP、携程APP等。
  2. Heartbleed。OpenSSL在实现心跳的扩展没有对输入进行适当验证,导致过读,可以读取服务器内存中的内容,即使协议没问题,实现有问题同样也会造成漏洞的产生。

总结

TLS因为比较复杂,所以或多或少总会有些漏洞,TLS也在慢慢地不断完善,因为很多东西基于数学原理,或者某天出现了快速因式分解等的方法,瞬间就可以破解TLS,也有人说美国安全局其实早就已经掌握某些技术能够监听全世界的加密流量了,加密和破解总是在不断地博弈,在博弈过程中进行自我升级,随着技术的发展以及大家对隐私和网络安全越来越关注,相信我们的网络环境一定会变得越来越安全。

零门槛学习https--(4)https协议详解

上一篇:零门槛学习https–(3)https的安全策略

https其实就是在TLS之上的http协议,所以各种头信息以及数据格式和http其实都一样,主要区别就在TLS,下面我们来看看TLS是如何工作的。

本章咱们讨论一下TLS的一个整体思路,和一些重要的细节,所有的细节请参看RFC文档

TLS握手

和TCP的握手一样,TLS在工作之前也需要握手,保证客户端和服务端的正确运行,下面的图片里面包含了TLS握手的整个过程。

下面是一次对https://www.baidu.com请求的TLS握手过程,我们结合上面的图一起来看看整个过程在实际连接中是什么样子的。

Client Hello

和TCP握手一样,客户端首先要告知服务端自己的来意,总共会传递以下信息:

1
2
3
4
5
6
7
8
9
10
11
1. 客户端支持的TLS协议版本,这里会有一个list,目前主流的是TLS1.2。
2. 一个随机数,我们记为CR,具体的作用是用来生成对话密钥,我们稍后会用到。
3. 支持的加密方法套件。例如:TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA,由四个部分组成:
1. 密钥交换算法。用来交换对称加密密钥的算法。
2. 加密算法。对称加密算法,真正的加密通讯内容的加密算法。
3. 报文认证信息码(MAC)算法。对通讯内容做一个摘要,然后附加到每一段消息尾部,
这样服务端就知道消息是否被更改。这一步要防止的是:虽然加密了,但是如果中间人随便更改一个字节,
也许要表达的内容就不一样了。
4. PRF(伪随机函数)。
4. 支持的压缩方法。
5. 扩展信息。扩展信息会有非常多,类似时间戳,域名等等信息,具体的可以参看RFC文档。

有一个扩展信息可以提一下,早先的TLS协议是没有域名信息的,造成一个问题就是,如果服务端采用一些虚拟化技术虚拟了很多主机,每个主机服务不同的域名,这时候有客户端向服务端发出TLS请求,服务端这时候并不知道应该把哪个证书返回,所以为了解决这个问题,在2006年TLS协议加入了域名扩展信息。

所以说,如果你在公司上网,即使你使用了HTTPS,公司依然能够知道你访问是t66y还是115,或者是taobao。有人也向官方指出这会泄露隐私,但是官方认为这是一个低安全级别问题,并且现在还没有什么好的解法,所以这个问题就搁置了。

1
2
3
4
5
红框中分别是:

1. 客户端随机数:CR。
2. 本次请求的域名。
3. 扩展信息,要求服务端直接返回证书的校验结果,校验方式是:OCSP。

Server Hello

1
2
3
4
1. 确认TLS协议的版本,如果客户端的协议服务端不支持的话,服务端会选择关闭这个连接。
2. 从客户端支持的加密方法中,选择一个加密方式,并告知客户端。
3. 生成一个随机数,我们记为SR,后续我们会结合CR生成对话密钥,稍后会用到。
4. 从客户端支持的压缩算法中,选择一个压缩算法。

1
2
3
4
5
6
7
8
红框中内容分别是:

1. 服务端随机数:SR。
2. Session Id。跟SSO的cookie的概念有点类似,就是当你这次握手成功之后,
下次直接带上这个session Id,如果服务端认为合法,那么就不用再进行握手,
直接可以开始用之前已经商量好的对称加密密钥开始通信了。
3. 服务端选择的加密套件。
4. 服务端设置了`status_request`的应答标志位,答应客户端会直接返回服务端证书的OCSP校验结果。

Server Certificate

这一步主要是返回证书链供客户端验证。

服务端返回了证书链,里面包含三张证书。

[Certificate Status]

这一步也是可选的,当Client Hello的时候,客户端会在extension里面要求服务端直接返回证书的校验结果,以加快访问速度,这是TLS的一个扩展,在RFC6066中规定了这一个扩展。当然即使客户端要求了,服务端在Server Hello的时候应答了,服务端也可以不返回,这样客户端就要自己去校验了。

服务端会返回OCSP验证的结果,这样,服务端可以缓存OCSP的结果,服务端就无需再次访问OCSP获取证书验证结果,提高客户端的访问速度。

说的直白一点,这一步的作用就是:服务端返回证书给客户端,然后再告诉客户端说,这个证书我已经验证过了,你放心用吧。客户端会选择信任中间证书,直接校验根证书,所以,这里即使被中间人攻击直接返回验证成功,但是因为客户端任然会通过操作系统内置的根证书来进行验证,这一步任然是安全的。

这里返回了OCSP的结果:successful。

[Server Key Exchange]

这一步也是可选的,取决于双方的加密方法,这里就不得不提到TLS的两种密钥协商方式:

  1. TLS_RSA_XXXX。这类算法里面,RSA的作用是Key Transmission,也就是说对称加密的密钥是由客户端生成,然后通过证书里面的公钥加密发送给服务端。如果采用的是RSA算法,那么这一步就不需要了。
  2. TLS_DHE_XXXX。这类算法里面,使用DH算法进行密钥协商,DH的作用就是Key Exchange,密钥是由客户端和服务端共同生成的。

DH密钥协商可以总结如下:

1
2
3
4
5
1. 通讯双方(张三、李四)需要先约定好算法参数(algorithm parameters):一个素数 p 作为模数,一个素数 g 作为基数(g 也称为“生成元”)。这两个算法参数是可以对外公开滴。
2. 对于张三而言,需要先想好一个秘密的自然数 a 作为私钥(不能公开),然后计算 A = ga mod p 作为自己的公钥(可以公开)。
3. 对李四而言也类似,先想好一个秘密的自然数 b 作为私钥(不能公开),然后计算 B = gb mod p 作为自己的公钥(可以公开)。
4. 张三和李四互相交换各自的公钥。
5. 然后张三计算出 k = Ba mod p,李四计算出 k = Ab mod p

1
2
3
4
上图中,服务器端把p、g还有服务端算出的DH公钥,组合成Pubkey,
用SHA512求得hash之后,用服务端私钥做RSA签名防伪。

客户端收到后,会用证书里面的公钥解密,验证签名正确性,然后拿出服务端DH公钥

[Client Certificate Request]

这一步也是可选的,要求客户端出示自己的证书,默认服务端不会向客户端索取证书。主要用于银行等金融领域,只有持有相应的证书(各类U盾)才允许客户端访问自己的网络,这里的客户端证书和CA证书类似,不过签发的一方通常是银行自己。

Server Done

这一步就是个标志位,告诉客户端,整个Server Hello阶段已经结束,轮到你回消息了。

Client Certificate

如果Server Hello的时候,服务端要求验证客户端证书,客户端会在这里给服务端发送自己的证书。

这是客户端在Server Hello Done之后发送的第一条数据。

Client Key Exchange

这条消息必须在Client Certificate(如果有的话)之后立即发送。这里的Key Exchange交换的就是pre-master key,有了pre-master key和我们之前生成两个随机数CRSR就能够计算出我们的对称加密的密钥了,这里总共分为两种情况:

  1. 如果采用的是RSA算法,这里就是一个客户端产生的48 byte的随机数,用服务端的公钥加密之后发送。这里需要用公钥加密的原因在于,前两个随机数都是明文传输的,而采用RSA方式传输密钥,如果三个随机数都是明文,那么就可以计算出对称加密的密文了,所以这里一定是要加密传输。
  2. 如果采用的是DH算法及各种变种算法(如:ECDHE),这里发送的就是客户端公钥。这个消息不用公钥加密,原因在于DH算法本身的作用就是在不安全的通信通道交换一个安全的加密密钥,真是的一个神奇的算法。

这里把DH算法的客户端公钥发送给服务端。

至于为什么不能全部都由客户端或者都由服务端生成呢?这里引用别的地方看到的一段话,解释得非常清楚:

1
2
3
4
5
6
7
8
9
10
11
不管是客户端还是服务器,都需要随机数,这样生成的密钥才不会每次都一样。
由于SSL协议中证书是静态的,因此十分有必要引入一种随机因素来保证协商出来的密钥的随机性。
对于RSA密钥交换算法来说,pre-master-key本身就是一个随机数,再加上hello消息中的随机,
三个随机数通过一个密钥导出器最终导出一个对称密钥。

pre master的存在在于SSL协议不信任每个主机都能产生完全随机的随机数,
如果随机数不随机,那么pre master secret就有可能被猜出来,
那么仅适用pre master secret作为密钥就不合适了,因此必须引入新的随机因素,
那么客户端和服务器加上pre master secret三个随机数一同生成的密钥就不容易被猜出了,
一个伪随机可能完全不随机,可是是三个伪随机就十分接近随机了,
每增加一个自由度,随机性增加的可不是一。

总得来说即使客户端或者服务器端被人做了手脚,采用的随机数生成函数变成了可控的随机,那么只要另外一方不是被同一人做手脚,整体通信任然是安全的。

Change Cipher Spec(客户端)

这一步就是个标志位,告诉服务端:客户端之后通信就开始使用之前约定好的加密方式来加密传输了。

Encrypted Handshake Message(客户端)

这一步的作用是把之前握手的所有消息,用加密套件里的hash算法计算出一个值,然后用刚刚协商好的对称加密的密钥进行加密,发送给服务端,即能验证之前发送的消息有没有被篡改,又能验证下服务端计算的密钥对不对,如果计算不对就解不出明文。

因为内容已经加密了,就看不到具体的东西了

Change Cipher Spec(服务端)

跟客户端一样,一个标示位,告诉客户端:服务端之后通信就开始使用之前约定好的加密方式来加密传输了。

Encrypted Handshake Message(服务端)

同样的,把之前发送的所有东西都计算hash,然后用自己计算出来的对称加密密钥加密,发送给客户端解密。至此,服务端在握手阶段就结束了。客户端在收到消息,并且解密验证通过了之后,客户端也就结束了。

TLS 数据传输

总得来说跟HTTP差不多,只是在每个包后面添加了一个由报文认证信息码(MAC)算法计算出来的hash,保证信息传输的健全性。

数据传输协议的是http

TLS session id

这个其实和SSO 登陆的cookie类似,有了cookie,第二次访问的时候服务端会去校验cookie是否有效,如果有效那就不用重新登陆了,如果cookie无效,那么就要重新登陆了。

session id也是如此,整个TLS握手过程还是比较复杂的,如果每个连接都这么握手一次,那体验也真是太差了,所以TLS设计了session id的概念,如果session id没有过期,那么就可以跳过握手阶段,直接开始加密传输。

所以有时候我们会发现,第一次访问某个HTTPS网站的时候很慢,但是第一次打开之后,第二次刷新,速度就很快了。

好了,到这里我们的TLS协议就介绍得差不多了,其实TLS协议还有非常多的细节,大家可以仔细参看RFC文档。TLS协议看起来似乎很完美,无法破解,但是现实中还是有很多基于TLS的攻击方式,下一篇就给大家来分享一下:

零门槛学习https–(5)常见的https攻击原理

零门槛学习https--(3)https的安全策略

上一篇:零门槛学习https–(2)https中s的秘密

上一篇我们已经把非对称加密RSA算法的原理已经摸清楚了,有了加密算法,我们就能够开始加密了。当然非对称加密还有好几种,这里我们只讨论RSA

RSA算法的局限性

在RSA的加密原理中,我们看到对明文m的要求是:

  1. m必须是整数。
  2. m必须小于n。

第一个条件很容易满足,文字可以转成arcii,而二进制文件没有小数。问题在第二个条件,通常n的选择是2048位,所以这也就意味着我们每加密一次的数据,二进制长度不得超过2048位,这个局限性就比较大了,因为 2048 / 8 = 256,也就是一次最多只能加密256 byte的东西,所以超出部分就只能分片加密了,而每次加密其实涉及到的计算量还挺大的,都是大数的幂次方计算,所以,如果在一个https请求中,完全采用RSA加密,那么客户端和服务端的计算量简直不可想象,特别是服务端,会造成用户体验异常低下。

非对称加密的解决办法

这里我们已经有了相对来说能够保证安全的方式,但是其计算量太大导致无法大规模应用,其他非对称加密的计算量同样非常大,那么,只能另寻他法了,剩下的算法就只有对称加密和摘要算法了,摘要算法不可逆,加密了之后就没法解了,肯定不适合,那么就只剩下对称加密了。

对称加密

之前我们没有直接用对称加密的原因是因为,如果对称加密的秘钥写死在客户端里面,那么很容易就被人拿到了,如果每次都随机生成,那么无法用加密的方式分发到对方手中。

结论

聪明的革命先烈们这时候就想到了,那我们用非对称加密来加密对称加密的密钥,然后用对称加密不就行了么?这样既避免了非对称加密的庞大的计算量,又能够避免对称加密的密钥被泄露,完美地解决了这个问题。

新的问题出现

上面的解决方案看起来似乎很完美,很快大家都开始实装,代码跑起来没有问题,加密解密也成功得运行了,而且当有人尝试拿到密文,也无从下手解密,突然,人们意识到一个问题:

MIM(man in the middle)中间人攻击

有个很常见的场景,假设用户A通过B的网关上网,那么A的所有流量都会经过B,也就是B能看到A的所有进出的流量,RSA在算法层面虽然没法破解,但是有人想到,RSA任然存在一个传递公钥的过程,并且这是明文的,那么就有如下场景:

1
2
3
4
5
6
7
1. A和服务器S进行通信,并且由S生成公钥和私钥,并在连接建立后,S把公钥给A。
2. 因为A经由B上网,也就是说S的公钥是经由B交给A的。
3. 这个时候,B可以伪装成服务端S1接受A的连接,并生成自己的公钥和私钥,再伪装成客户端C向S发送请求。
4. S把公钥给了B,B再把自己的公钥给A,所以就变成了:
5. A和B通信,B再和S通信,A虽然加密了,但是是用的B的公钥,所以B有私钥能够解密,然后再用S的公钥加密,发送给S,S任然能解密。
6. 整个过程神不知鬼不觉,B就悄悄查看了所有的通信内容
7. 甚至有可能在A申请把自己网银里面的钱转给D时,B也可以偷偷改成自己的,S认为这是A发出的,就把钱转给了B。

问题的原因

显然,人们深刻地认识到,因为数据的可复制性,无论是什么算法,都无法改变数据会被中间人窥探甚至修改的问题(以后量子加密的出现或许能解决这个问题,因为量子一旦被观测,叠加态就会坍缩,接收者就能够知道自己接收到的数据是否被窥探了)。

问题源于生活,那么在生活中一定可以找到答案。你把钱给了银行,银行给你承诺,你想用钱的时候一定会还给你,银行在这里承担了一个被所有人信任的角色,在计算机里面其实也一样,于是,人们为计算机通信引入了一个第三方:CA(Certificate Authority)。

数字证书

CA又称为证书颁发机构,主要用于颁发数字证书,用一个无法篡改的数字证书来表明身份,防止数据在通讯过程中被篡改和窥探,数字证书通常分为以下几种:

  1. 个人身份证书。
  2. 企业或机构身份证书。
  3. 支付网关证书。
  4. 服务器证书。
  5. 企业或机构代码签名证书。
  6. 安全电子邮件证书。
  7. 个人代码签名证书。

数字证书如何生成?

  1. 首先,CA要生成一个自己的公钥和私钥,这里通常只生成一次就行了。
  2. 有人向CA申请证书,把自己的各种身份信息,营业执照,域名,到期时间等等的信息发给CA。
  3. CA严格审核申请者的信息,申请通过后,CA为申请者生成一对公钥和私钥(也可以自己生成),把CA的签发人、地址、签发时间、过期失效时间等信息,加上申请者的基本信息以及DNS、域名、公钥等基本信息整合到一起,生成一个证书,这里的证书还未签名,仅仅是很多信息的聚合而已。
  4. 通过通用的摘要算法(通常是sha256)将信息摘要提取出来,然后用CA的私钥加密,这里因为私钥严格保存,所以别人无法伪造签名,除非拿到私钥。
  5. 把上一步计算出来的信息摘要附加到第三步生成的证书中,之后,这个证书就称为了一个被CA签名过的证书了,因为私钥被CA严格保存,所以没有人能够模仿CA的签名,这也就是签名安全的根本了。
  6. 服务端把这个证书挂到自己的服务器上去,就能够供人使用了。

以上整个过程称为:Public Key Infrastructure(PKI,公开密钥基础建设)

如何验证证书的有效性呢?

  1. 客户端向服务端发起请求,服务端返回数字证书。
  2. 客户端拿到证书之后,首先解析证书,拿到被私钥加密的摘要密文,然后再拿出证书的元数据。
  3. 用CA的公钥进行解密,CA的公钥是公开的,任何人都可以获取(会内置在操作系统里面,或者在CA的官方网站中可以获取),解密后拿到摘要的原文。
  4. 对元数据再次执行摘要算法,拿到摘要信息,然后和第三步的结果进行对比,只有当结果一致的时候,才认为证书是有效的。

整个过程可以理解为下图:

如果我们获取CA的公钥的时候,已经被替换了怎么办?

设想一下,当我们获取CA公钥的时候,已经被人替换了,这时候真正的证书反而没法解析,只有被伪造的证书才能被解析了,这里就有了证书链的概念。

证书链

通常在一个证书链中包含以下三种结构:

  1. end-user。终端用户,也就是https中真正用来加密通信的证书。
  2. intermediates。给end-user签发证书的CA的证书,主要用来校验end-user的证书是否合法的证书。
  3. root。root也是CA证书,区别在于,root证书是给intermediates签发证书的,用来校验intermediates的合法性。

证书链的签发。

  1. 首先,root自己先生成一对公钥和私钥,然后用自己的私钥给自己自签名,因为root的绝对信任。
  2. 二级CA向root申请证书,root按照上面提到的数字证书的生成方式,先给CA生成一对公钥和私钥,然后
  3. 把CA的各种信息算出摘要,再用自己的私钥加密,加上给二级CA生成的公钥,就组成了一张CA的证书。
  4. 然后有用户向二级CA申请证书时,按照这个步骤一步步签发,就形成了证书链。
  5. 证书一级一级签发,中间无法伪造,因为root证书的绝对安全,保证了整个证书链的安全。
  6. 证书链是有长度限制的,root颁发证书的时候会添加此字段,所以证书申请者无法再为别人签发证书。

CA的类型

上面提到intermediates和root都是CA证书,所以CA其实也就分为root CAs和intermediates CAs,root CAs可以认为是一个品牌,而intermediates CAs则是真正使用这个品牌生产产品的企业,举个例子:

1
2
3
4
哇哈哈母公司,拥有哇哈哈品牌,但是并没有自己的产品,做的只是给子公司授权品牌,也就是root.
子公司A,生产矿泉水,是一个intermediates.
子公司B,生产八宝粥,是一个intermediates.
子公司C,生产橙汁,也是一个intermediates.

为什么不能用root CA的私钥直接签名?

主要还是从安全性上考虑,私钥是需要放到服务器上去做签名用的,所以不可避免可能服务器会被人攻破,导致私钥泄露,所以root的私钥隔离得越远越好,如果某个intermediates的私钥被窃取了,那么用root的私钥再签发一个intermediates证书即可,不会威胁到整个root证书下所有人的安全。root CA的私钥一般锁在大金库里面,完全得物理隔离,以下是godaddy对这个的解释:

intermediate certificates are used as a stand-in for our root certificate. We use intermediate certificates as a proxy because we must keep our root certificate behind numerous layers of security, ensuring its keys are absolutely inaccessible.

root证书的获取

回到我们最初的问题,获取证书的时候就已经被替换了怎么办?这里我们只需要保证root证书的获取没有问题,那么只要其他中间证书被替换,最后校验root证书这里还是没法通过,那么如果保证root证书的获取是安全的呢?

  1. 首先,成为一个CA的一个前提是你要向微软等操作系统厂商申请加入他们的白名单,就是把自己的root证书内置在操作系统里面,随着系统的安装和升级的过程就已经被安装到操作系统里面。
  2. 浏览器等终端在验证的时候,就直接调用操作系统的接口,获取证书,用来验证,例如chrome和ie。
  3. 有些浏览器类似firefox有一套自己的证书系统,是随着安装firefox的时候,安装到本地,这样,即使操作系统的证书被篡改了,firefox依然有能力验证证书的有效性。

root证书并不会很多,目前主流的基本就只有:Symantec(VeriSign/GeoTrust)、Comodo、GoDaddy,也是被这几家垄断,签发证书躺着赚钱,所以现在也有一些公益的组织开始提供免费证书。

CRL(Certificate Revocation List)

CRL是证书吊销列表,早先的浏览器通过这种方式把这个列表缓存在本地,用以验证证书是否有效。但是因为客户端时间不可控,所以这种方式还是存在一定的风险。

OCSP协议(Online Certificate Status Protocol)

除了CRL的方式,还有一种在线证书状态协议OCSP,验证的时候会从CA直接获取验证结果,主要用于证书过期吊销等的验证,主流浏览器都已经支持,可以防止因为缓存或者客户端时间不正确导致的证书过期时间判断不准确的问题。

如何验证二级CA的证书?

浏览器和操作系统里面只内置了根证书,那么那些二级CA的证书又该如何验证呢?

这里有两种情况:

服务端没有返回中间证书

我们来看看谷歌的证书:

证书颁发机构信息访问,这一栏里面标示了此证书的证书颁发机构如何访问和验证,标红框的地址就是CA的证书下载地址,下面还有OCSP协议的验证地址。

服务端返回了中间证书

比较适用于实际情况,因为上一种情况会产生额外的http请求,访问速度就会比较慢了。

这里返回了三个证书:

证书链类似下面这样,可以自由配置:

1
2
3
4
5
6
7
8
9
-----BEGIN CERTIFICATE-----
自己的crt证书
-----END CERTIFICATE-----
-----BEGIN CERTIFICATE-----
父级的crt证书
-----END CERTIFICATE-----
-----BEGIN CERTIFICATE-----
父级的父级crt证书
-----END CERTIFICATE-----

end-user证书的类型

申请者能够申请的证书种类非常多,价格相差也很大,主要有以下几种:

  1. OV(Organization Validation SSL),以某个组织为维度签发证书,通常这都是一个公司,域名采用通配符的方式来签发,以适应组织业务的灵活发展,例如:*.tmall.com
    ,也就是任何tmall.com的三级域名都可以使用这个证书,避免重复申请。使用OV证书的网站访问时候类似下面这样:

  2. DV(Domain Validation SSL),一个域名对应一个证书,无法使用通配符,这是最便宜的证书,申请无需人工干预,不需要组织信息,因为价格便宜,由某CA最早推出来之后,很受欢迎,但是因为没有人工审核,很多犯罪网站都使用DV。

  3. EV(Extended Validation Certificate),扩展验证证书,价格最高,审核最严,也是安全性最高的证书,能够使用一些安全性更高的加密算法,例如椭圆曲线算法,访问时还能在浏览器地址栏显示绿色,并且显示组织的名称,看起来非常高大上,如下图:

总结

目前整个互联网建立在对CA信任的基础上,要是CA作恶,那么整个互联网就会混乱了。之前就有发生过有CA误签发google证书,导致google差点被中间人攻击。

至此,整个https的加密原理和安全认证机制理论知识我们都已经讲完了,我们来回顾一下:首先客户端先校验证书,从服务端证书到二级CA再到根证书,然后双方生成一个对称加密的密钥,用证书里面的公钥加密,发送给服务端,服务端用自己的私钥解密,拿到对称加密的密钥之后,双方开始通信。

这套加密系统其实适用于所有客户端和服务端的通信,所以网景公司在1994年推出浏览器时,同时推出了ssl,应用到http就变成了https。后面咱们来看看https中,ssl协议的一些具体细节。

零门槛学习https–(4)https协议详解

零门槛学习https--(2)https中s的秘密

上一篇:零门槛学习https–(1)为什么我们要用https

非对称加密在目前的计算机领域内是非常安全的,主要还是受制于目前的计算能力,或许某天量子计算机的出现会改变这个局面。RSA的本质就是一个数学算法,所以本篇即使你不懂任何计算机知识,也能理解RSA算法的原理,但是其中会涉及到大量的数学知识。

RSA

前面已经介绍过RSA的名称由来,下面来介绍下RSA的安全本质:大整数的因数分解

举个例子:求77的因数分解,很简单,解是7和11,或许你在高中或者初中就学过这个,很诧异这能成为现代互联网安全的基础?那么你可以看看下面这个数字:

1
2
3
4
5
6
7
8
9
12301866845301177551304949
58384962720772853569595334
79219732245215172640050726
36575187452021997864693899
56474942774063845925192557
32630345373154826850791702
61221429134616704292143116
02221240479274737794080665
351419597459856902143413

这个就是人类目前破解过的最长的一个整数,在2009年才破解成功,转成二进制就是768位,而现在普遍都是2048位的加密,如果非要破解,那么所需时间是一个天文数字。如果有一天,有人发明了简单的因数分解的方式,那么RSA就很容易被破解了,目前,基本上只有暴力破解,也就是一个数一个数地测试。

RSA的思路

上面那个数等于下面这两个数的乘积,计算这两个数的乘积很简单,计算机在瞬间就可以给出答案,但是只有两个数的乘积,而让你求出其因数,就几乎是不可能了,当然这是基于大整数的情况下,简单的整数,通过暴力破解,还是可以求出来的。

1
2
3
4
5
6
7
8
9
10
11
33478071698956898786044169
84821269081770479498371376
85689124313889828837938780
02287614711652531743087737
814467999489
    ×
36746043666799590428244633
79962795263227915816434308
76426760322838157396665112
79233373417143396810270092
798736308917

我们把这两个数分别称为pq,这两个数的乘积称为n,从这个角度出发,只要加密者拥有pq,很容易算出乘积n,而尝试破解的人,只有n,就没法算出pq了。

如何生成公私钥?

本质已经知道了,那么我们来生成RSA的公钥和私钥吧,生成公钥和私钥总共分为五步:

1. 随机选择两个质数p和q

质数又称素数,具体的特性和定义请参考这里

比如这里我选择了p=17,q=23

2. 将这个两个数相乘得到n

17 * 23 = 391391换算成二进制就是110001000,这里的位数就是9位,这就是我们密钥的长度。选择RSA密钥长度的时候,其实就是选择这个n的长度,长度越大,安全性越高,当然加密和解密的时候耗费的计算量也更大。

3. 计算n的欧拉函数φ(n)

欧拉函数的作用是求得大于1小于n的所有质数的个数,例如符合φ(8)的情况是:2, 3, 5, 7,也就是φ(8)的值为:4,具体有关欧拉函数的其他特性请参考这里

我们这里φ(n)可以这么求值:

1
2
3
φ(n) = (p-1)(q-1).

这里,φ(n) = ( 17 -1 ) * ( 23 - 1 ) = 352

4. 随机选择一个整数e, 需要符合条件:1 < e < φ(n)并且e和φ(n)互质

也就是说,eφ(n)不能有公约数,并且φ(n)不能是e的倍数。

举个例子:φ(n) = 352,所以e可以选的数有3, 5, 7, 11 ....,这里我选择17

在现实场景中,通常e的选择是65537,为什么要选择这个数呢?请参看这篇文章,解释得非常清楚,总的来说,比65537大的数,在现有的硬件和软件场景下,会造成计算变慢,而比65537小的数,在安全性上会有减弱,所以65537是一个折中的选择。

5. 计算e对于φ(n)的模反元素d

求模

求模操作在大部分计算机语言中操作符都是%,比如:10 % 3 = 1,指的是余数。

同余

三横的等号,代表同余,两个整数ab,若它们除以正整数m所得的余数相等,则称 ab对于模 m同余,例如:

1
10 ≡ 1 (mod 3)

10 和 1 对于 3 同余。

模反元素

模反元素也成为模逆元

一整数a对同余n之模逆元是指满足以下公式的整数 b

也可以写成以下的式子

整数 a 对模数 n 之模逆元存在的充分必要条件是 a 和 n 互素,若此模逆元存在,在模数 n 下的除法可以用和对应模逆元的乘法来达成,此概念和实数除法的概念相同。

以上是维基百科对于模反元素的介绍,将eφ(n)代入公式就变成了:

1
e * d ≡ 1(mod φ(n))

也就是说e * d的结果除以k * φ(n)的余数是1,所以我们的公式就变成了:

1
e * d - 1 = k * φ(n);

eφ(n)代入:

1
231 * d - 1 = k * 352

转变一下位置:

1
231x - 352y = 1

就变成了一个二元一次方程,初中时候就求过二元一次方程的解,但是必须得有两个二元一次方程才能求出解,不然就是一个直线方程了,这里我们采用扩展欧几里得算法来进行求解。

欧几里得算法

我们先看看欧几里得算法的定义:欧几里得算法又称为辗转相除法,是一种求最大公约数的方法,其核心思想如下图:

假设我们要求12076的最大公约数,可以这么计算:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
首先选择大的数减去小的数

120 - 76 = 44

现在我们剩下76 和 44两个数,继续第一步操作

76 - 44 = 32

44 - 32 = 12

32 - 12 = 20

20 - 12 = 8

12 - 8 = 4

8 - 4 = 4

4 - 4 = 0

所以,我们就可以求出,12076的最大公约数是4

扩展欧几里得算法

看名字就可以知道这是欧几里得算法的扩展,其核心思想如下:

已知a和b,求解一组x, y使得ax+by=Gcd(a, b)=d (Gcd(a, b) 表示a和b的最大公约数)

用类似辗转相除法来求解这个等式17x - 352y = 1

1
2
3
4
352 = 17 * 20 + 12
17 = 12 * 1 + 5
12 = 5 * 2 + 2
5 = 2 * 2 + 1

改变一下等式两边

1
2
3
4
12  = 352 - 17 * 20
5 = 17 - 12
2 = 12 - 5 * 2
1 = 5 - 2 * 2

把等式代入上一个等式

1
2
3
4
5
6
7
8
9
10
1  = 5 - 2 * 2
1 = 5 -( 12 - 5 * 2 )* 2 // 代入等式:2 = 12 - 5 * 2
1 = 5 - 12 * 2 + 5 * 4
1 = 5 * 5 - 12 * 2
1 = ( 17 - 12 ) * 5 - 12 * 2 // 代入等式:5 = 17 - 12
1 = 17 * 5 - 12 * 5 - 12 * 2
1 = 17 * 5 - 12 * 7
1 = 17 * 5 - ( 352 - 17 * 20 ) * 7 // 代入等式:12 = 352 - 17 * 20
1 = 17 * 5 - 352 * 7 + 17 * 140
1 = 17 * 145 - 352 * 7

即可得出:x = 145, y = 7

结果

在第五步中我们想要的d就是这里的x,所以,d = 145

作为一个直线方程,这里理论上这里会有无数个整数解,的确,尝试一下就发现,所有整数解都是能计算的,只是数字越大,计算量越大,但是要注意,这里的d不能小于0。

组合公钥和私钥

到这里,公钥和私钥所需要的东西都齐全了,({n, e})就是公钥,而({n, d})就是私钥,一般会使用ASN.1来表达。

加密

首先,我们用公钥加密。

要加密的数据m

m需要满足以下条件:

  1. m必须是正整数(字符串之类的可以取arcii)
  2. m必须小于n

加密公式

me ≡ c (mod n)

这里的c就是我们的密文。

上面我们计算的n是:391,e是:17, 那假设我们要加密的内容是:11,所以我们的密文就是:198

这里非常需要注意的一点就是,需要使用一些大数计算的方式,而不能使用普通的double之类的类型,因为double类型,还有js等语言的默认的数字类型尾数位只有52位,无法精确表达比2的53次更大的数字

解密公式

cd ≡ m (mod n)

c是:198,d是:145,n是:391,解出我们的明文是:11,至此,我们的整个加密和解密过程就结束了。也许你会好奇,为什么这么算就能算出相等的结果,下面就来证明一下。

公式证明

我们的加密公式是:

me ≡ c (mod n)

我们的解密公式是:

cd ≡ m (mod n)

根据上面的规则,c可以这么表达:

c = me - kn

c代入我们的解密方程:

(me - kn)d ≡ m (mod n)

因为这是一个同余等式,等号左边加减n都不会影响等式,所以我们可以把等式简化为

med ≡ m (mod n)

当我们在求d的时候有同余等式:

ed ≡ 1 (mod φ(n))

这个等式也可以写成这个样子:

ed = h * φ(n) + 1

代入刚刚简化后的等式:

mhφ(n)+1 ≡ m (mod n)

到这里,只要我们能够证明这个等式是成立的,那么就能够证明我们的加密和解密公式是成立的,这里就有两种情况了:

m和n是互质的关系

欧拉定理)如果两个正整数a和n互质,则n的欧拉函数 φ(n) 可以让下面的等式成立:

aφ(n) ≡ 1 (mod n)

我们把mn代入公式可以得到:

mφ(n) ≡ 1 (mod n)

因为mn互质,那么以下等式依然成立:

(mφ(n))h * m ≡ m (mod n)

对指数稍微换算一下就能得到我们之前的等式了,mn互质的情况下,我们很容易就能证明之前的等式成立。

m和n不是互质的关系

首先我们有n = p * q,因为mn不是互质的,而因为n是两个质数相乘的结果,也就是说p或者q就是mn的一个公约数,所以有m = p * k,或者m = q * k。假设pmn的公约数,那么就有m = p * k

然后因为pq都是质数,质数和除了是自己倍数之外的数都互质,又因为m必须小于n,所以这里k取值必然不能是q或者更大的数,所以我们有一个结论是:k必然和q互质。

k * p必然和q互质,把k * pq代入,所以我们能够得出以下同余等式:

(kp)φ(q) ≡ 1 (mod q)

因为k * pq互质,所以可以有:

(kp)h * φ(q) + 1 ≡ kp (mod q)

而我们之前已经求出:

ed = h * φ(n) + 1

代入刚刚的式子:

kped ≡ kp (mod q)

假设有t,使得求模运算可以写成:

kped = t q + k p

转换一下等式:

kped - 1 = t * q

先假设k * pt互质,也就是说k * p能够整除q,因为pq是互质关系,而上面我们也得出k必然和q互质,所以我们的假设k * pt互质不成立,上方我们得出k必然和q互质,由此可得p必然能够整除t才能使得等式成立,也就是有:t = t * p,由此可得如下等式:

kped = t pq + kp

因为m = kp,而n = pq,所以:

med = tn + m

写成同余等式可得:

med ≡ m (mod n)

这里就得出了我们刚刚求出的等式。

结论

到这里,我们已经证明了两种情况下等式都是成立了,也就是明文通过公钥加密之后,使用私钥也必然能够得到原本的结果,RSA算法也就成立了。

RSA中的对称性

其实你会发现,RSA里面,公钥加密的东西私钥能够解密,同样的,从公式中发现,私钥加密的东西公钥也能解密,这也是非常重要的一个特性。那么,为什么不能把({n, d})作为公钥给用户呢?因为({n, e})中,n是公开的,e基本都使用常数65537,所以很容易就被猜到了,因此,虽然在算法上都能加密和解密,但是只把({n, e})作为公钥,公钥和私钥有非常严格的区分。

总结

有了这么安全的加密方式,那么我们就可以开始给HTTP加密了,那么https究竟是怎么做的呢,一起来看看:

零门槛学习https–(3)https的安全策略

零门槛学习https--(1)为什么我们要用https

目前几乎主流的所有网站都使用了https,而且苹果也强制要求开发者使用https要传输数据,我们先不说什么是https,以及为什么需要https,因为任何技术的诞生都是因为现实中遇到的问题,而诞生了这个技术去解决问题,那么,https是为了解决什么问题呢?

http

http是一个非常伟大的协议,几乎承载了整个互联网,它有一个特点,那就是明文传输,如今家用的一个最简单的网络情况往往是,你连着路由器,路由器连着一个猫,然后接入电信运营商,这时候,我们就遇到了两个问题:

  1. 路由器被人动了手脚,跟女神聊天的聊天记录全被别人看到了。
  2. 随便打开一个网页,右下角都会弹出一些诡异的广告。

也许你不知道也不在乎自己的聊天记录被人看到了,那么当你兴致勃勃打开115准备欣赏大片的时候,右下角的广告一定能让你炸毛了,比如下面这样子:

因为http是明文传输,那么想查看别人的信息,也就很容易了,同样,在你返回的http页面插入一个js脚本也是轻而易举,运营商的广告就是这么来的,非常无耻。

http有多不安全?

打开Wireshark随便抓一个包看看

cookie轻松就拿到了,在一些安全性不是很高的网站,有了cookie,你就相当于在短时间内登陆了这个账号,甚至都不用账号密码。

返回的html也完整得能被解析出来,运营商插入一段广告JS还好,在一些安全性不是很高的网站插入监听键盘的脚本,然后就能拿到账号密码了,再通过撞库等方式,你在很多网站或许都不安全了。

解决办法

聪明的你肯定想到了,既然都是明文传输惹得祸,那么我加密就行了呀。的确,加密就行了,但是这么简单的一句话,里面可是包含了无数革命先烈的研究成果,造就了https的由来。

加密方法

既然提到了加密,那么无非就是三种加密方法:

  • 摘要算法。摘要算法严格意义上来说其实也不算是加密方法,最大的特点是不可逆,并且普遍防碰撞性较好。典型的有MD5,sha256等算法。现在有一些彩虹表,存储了大量的摘要信息以及其原文,并且还在不断增加,加上一些暴力破解技术,例如ophcrack等工具,几秒内即可破解windows登陆密码。

  • 对称加密。典型的一个对称加密算法就是凯撒加密,比如原文是:1234,那么约定一个密钥1,加密的时候每一位都+1,所以密文就是:2345,解密的时候很简单,只要每一位都-1即可,运算速度很快,唯一的缺点是,两个人之间的密钥有一个人的泄露了,那么所有的通信都不安全了,并且传递密钥的时候除非当面给,不然都不安全。

  • 非对称加密。最大的特点是通信双方的密钥是不一样的,并且加密者的密钥只能用于加密,无法解密,只有解密者的密钥能解密。典型的一个是RSA,几乎是奠定了现代互联网安全的基石,在HTTPS中被大量采用,其他例如椭圆曲线加密算法(ECC),以及ElGamal等算法。RSA三个字母取自三位发明者的首字母:Ron Rivest、Adi Shamir、Leonard Adleman,由这三位数学家在1977年提出。

对称加密虽然也很有多种方式,但是相对来说还是比较好理解,但是非对称加密用一个密钥加密,却能用另外一个密钥解密,这个可能就不大好理解了,下面跟大家主要聊一聊非对称加密中的RSA具体是如何实现的。

零门槛学习https–(2)https中s的秘密

一个使用Launch-Screen但是无法显示图片的坑

使用StoreBoard来作为Launch Screen的时候,通常做法是在Store Board里面加一个UIImageView,然后里面设置相应的图片就OK了,例如下面这样:

我把app-starpup这张图片放到了Assets.xcassets里面,然后在storyboard里面引用了一下,一切都很简单,看起来也毫无问题,但是编译完到启动的时候,发现无论如何这样图片都显示不出来,我以为是同时设置了Launch Image的问题,然后调试了差不多一个小时,最后终于意识到了问题所在:Assets.xcassets里面的文件在启动的时候还没有被加载,把图片放到某个文件夹里面,然后再引用文件夹里面的图片,问题就解决了。

多次setRootViewController导致view无限叠加的问题

背景

最近在开发一个iOS项目,有这么几个页面:登录页、引导页、主页。因为这几个页面之间几乎没有关联,所以就做成了3个不同的viewController,然后通过直接[UIWindow setRootViewController:]的方式来进行页面切换,其中,引导页和主页是单例,登陆页因为某些原因每次使用都会实例化。

出现的问题

正常使用流程是:引导页-> 登录页 -> 主页,流程很顺,但是在退出后就出问题了,在主页退出后,重新展示登录页,然后在登陆页登陆完了之后,调用[UIWindow setRootViewController:]设置主页单例,打了断点发现逻辑已经调用,但是发现无论怎么设置,都没用,屏幕上依然是登录页,主页并没有显示出来,非常疑惑。

问题原因

打开xcode的调试功能:View UI Hierarchy,就能看到类似如下的场景了。(保密需要没有用自己的图)

最下面是引导页,然后中间是登录页,然后最上面是主页,然后因为退出之后又显示了登陆页,而登录页不是单例,所以在主页上又覆盖了一层登录页,而因为主页是单例,所以在第二次setRootViewController之后,并没有生效。而看了一下setRootViewController的作用,主要逻辑就是把要添加的viewController的view添加到window上,UIWindow本身就是一个view。

危害

这个问题其实还是挺有风险的,如果我的主页不是单例,那么我甚至都发现不了这个问题,只会一层一层往上叠加,就会造成内存泄露了。

解决方法

知道了原因,解决起来就轻松了,两种解法:

  1. 不使用setRootViewController的方式,而且采用pushViewController或者presentViewController的之类的方式去显示页面。
  2. 修改setRootViewController,在每次调用之前先删掉UIWindow上所有的view,然后在添加。

其实正常来说,还是推荐使用第一种方式来切换页面,而我因为改动比较大,而且时间比较急,所以采用了第二种方式,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
- (void)setRootViewController:(UIViewController *)rootViewController
{
//remove old rootViewController's sub views
for (UIView* subView in self.rootViewController.view.subviews)
{
[subView removeFromSuperview];
}

//remove old rootViewController's view
[self.rootViewController.view removeFromSuperview];

//remove empty UILayoutContainerView(s) remaining on root window
for (UIView *subView in self.subviews)
{
if (subView.subviews.count == 0)
{
[subView removeFromSuperview];
}
}

//set new rootViewController
[self addSubview:rootViewController.view];
[super setRootViewController:rootViewController];
}

伟大的单元测试

什么是单元测试?

在计算机编程中,单元测试(英语:Unit Testing)又称为模块测试, 是针对程序模块(软件设计的最小单位)来进行正确性检验的测试工作。程序单元是应用的最小可测试部件。在过程化编程中,一个单元就是单个程序、函数、过程等;对于面向对象编程,最小单元就是方法,包括基类(超类)、抽象类、或者派生类(子类)中的方法。

举个例子

待测试类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
"use strict";

class Car {

constructor() {
this.status = "stop";
}

// 开车
start() {
this.status = "start";
this._lockDoor();
}

// 停车
stop() {
this.status = "stop";
this._unlockDoor();
}

// 锁车门
_lockDoor() {
console.log( "door locked" );
this._lockFrontDoor();
this._lockBackDoor();
}

// 解锁车门
_unlockDoor() {
console.log( "door unlocked" );
this._unlockFrontDoor();
this._unlockBackDoor();
}

_lockFrontDoor() {
console.log( "front door locked" );
}

_lockBackDoor() {
console.log( "back door locked" );
}

_unlockFrontDoor() {
console.log( "front door unlocked" );
}

_unlockBackDoor() {
console.log( "back door unlocked" );
}

};

module.exports = Car;

以逻辑单元为最小单位的测试

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
"use strict";

const assert = require( "assert" );

const Car = require( "./sample" );

describe( "test Car", function() {

it( "start car", function () {
let car = new Car();

assert.equal( car.status, "stopped" );

let locked = false;
// MOCK
car._lockDoor = function () {
locked = true;
}

car.start();

assert.equal( car.status, "running" );
assert.equal( locked, true );

} );

it( "lock door", function () {
let car = new Car();

let frontDoorLocked = false;
let backDoorLocked = false;
car._lockFrontDoor = function () {
frontDoorLocked = true;
};

car._lockBackDoor = function () {
backDoorLocked = true;
};

car._lockDoor();

assert( frontDoorLocked, true );
assert( backDoorLocked, true );

} );

} );

以业务单元为最小单位的测试

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
"use strict";

const assert = require( "assert" );

const Car = require( "./sample" );

describe( "test Car", function() {

it( "start car", function () {
let car = new Car();

assert.equal( car.status, "stop" );

let _lockFrontDoor = car._lockFrontDoor;
let _lockBackDoor = car._lockBackDoor;

let frontDoorLocked = false;
let backDoorLocked = false;

car._lockFrontDoor = function () {
frontDoorLocked = true;
_lockBackDoor();
};

car._lockBackDoor = function () {
backDoorLocked = true;
_lockFrontDoor();
};

car.start();

assert.equal( car.status, "running" );
assert.equal( frontDoorLocked, true );
assert.equal( backDoorLocked, true );

} );

} );

一些单元测试原则

  • 单元测试必须由最熟悉代码的人(程序的作者)来写。
  • 单元测试应该在最低的功能/参数上验证程序的正确性。
  • 单元测试过后,机器状态保持不变。
  • 单元测试要快(一个测试运行时间是几秒钟,而不是几分钟)。
  • 单元测试应该产生可重复、一致的结果。
  • 独立性,单元测试的运行/通过/失败不依赖于别的测试,可以人为构造数据,以保持单元测试的独立性。
  • 单元测试应该覆盖所有代码路径,包括错误处理路径,为了保证单元测试的代码覆盖率,单元测试必须测试公开的和私有的函数/方法。
  • 单元测试应该集成到自动测试的框架中。
  • 单元测试必须和产品代码一起保存和维护。

单元测试的好处

  • 保证现有代码的功能都是正常的。
  • 若不清楚代码而修改了一个逻辑,运行一下单元测试就知道影响到了什么地方。
  • 反向推动代码做到一个单元的功能最小化。
  • 写单元测试的过程中可以重新梳理自己的代码,找出bug。
  • 重构系统而不改变功能时,对照单元测试来写自己的功能就可以,即使之前的代码不是自己写的。

单元测试不好的地方

  • 时间成本,直接导致人力成本上升。不过也有可能前提代码质量上的提升可以减少后期很多修BUG的时间。
  • 有些外部依赖较多并且逻辑相对简单的时候,初始化外部依赖或者mock的精力复杂度可能已经超过了业务代码本身。
  • 设计影响。有时候,为了方便测试,甚至已经影响到你的业务代码的逻辑。
  • 需求快速变化期间。有时候需求变化非常快速,单元测试会降低业务变化的敏捷度。

MOCK

MOCK 简单来说就是纸老虎。

业务代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
"use strict";

const DB = require( 'database' );

class Mock {

getList() {
const result = DB.query( "select * from table" );
for( let i = 0; i < result.length; i ++ ) {
// do somting
};

return result;
}

};

module.exports = Mock;

测试代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19

const Mock = require( './mock' );

const DB = require( 'db' );

describe( "test Mock", function () {

it( "get list", function () {
DB.query = function ( sql ) {
return [ 1, 2, 3 ];
};

const car = new Car();
const result = car.getList();

assert.equal( result, [ 1, 2, 3 ] );
} );

} );

为什么我的代码需要mock?

优点

  • 可以不用去关注一些底层支撑系统,例如DB,如果不MOCK,那么还需要初始化一个DB,使用前使用后都要去清理环境,给自己擦屁股。
  • 可以更加关注于自己业务代码的业务逻辑,例如去初始化一个DB,需要很多配置信息。
  • 不会因为数据库的故障导致自己的单元测试失败。
  • 隔绝别的模块,别的模块即使只申明了接口,也可以正常跑完自己的单元测试。

缺点

  • 如果mock对象很复杂,属性很多,方法也很多,那就需要自己去录制相当多的工作去实现这个mock.
  • 有些语言难以mock。一些强类型语言和编译型语言对于mock还是比较难的。
  • mock会掩盖某些环境。比如各种输出的时间格式会根据用户的环境而定,不同的地域也会有不同的格式,这个时候用mock返回自己所在地区或者自己所在环境的格式,看似单元测试全部通过,实则埋下了隐患。
  • 在不清楚被mock对象的所有边界行为时,可能会导致程序没有处理一些边界问题而超出预期。

TDD

TDD(Test Drive Development),单元测试带来的一个非常好的开发方式。TDD思想让你在写代码之前,就先写单元测试,这个时候可以让你把整个代码从整体层面上运行起来,然后再去实现每个方法具体的功能。

优点

  • 在设计阶段,划分方法,划分类的时候,无需关注某个类或者方法的具体的实现,只要达到功能即可。
  • 在实现某个类或者某个方法的时候,无需关注这么设计是否合理,直接按照方法名实现方法即可。

BDD

BDD指的是Behavior Drive Development,也就是行为驱动开发。这里的B并非指的是Business,实际上BDD可以看作是对TDD的一种补充,当然你也可以把它看作TDD的一个分支。因为在TDD中,我们并不能完全保证根据设计所编写的测试就是用户所期望的功能。BDD将这一部分简单和自然化,用自然语言来描述,让开发、测试、BA以及客户都能在这个基础上达成一致。因为测试优先的概念并不是每个人都能接受的,可能有人觉得系统太复杂而难以测试,有人认为不存在的东西无法测试。所以,我们在这里试图转换一种观念,那便是考虑它的行为,也就是说它应该如何运行,然后抽象出能达成共识的规范。如果你用过JBehave之类的BDD框架,你将会更好的理解其中具体的流程。这里我推荐一篇具体阐述的文章。

DDD

Deadline Drive Development.

软件开发进度表

时间 汇报进度 真实进度
第一天 20% 5%
第二天 50% 10%
第三天 70% 10%
第四天 80% 10%
第五天 90% 15%
第六天 100% 100%

iOS中实现高精度定时器的方法

背景

最近自己业余时间在开发一个录音软件,要画一个声音的音频图,大概类似下面的样子:

在一秒钟内要取大概20次当前声音的大小,用以绘制成一幅图,那么首先,想当然的当然是用NSTimer了,写了代码如下:

1
2
3
4
5
6
// 当然这里把每秒钟需要的次数设置成一个变量会更好。
[NSTimer scheduledTimerWithTimeInterval:1 / 20
repeats:YES
block:^(NSTimer * _Nonnull timer) {
// do somting.
}];

block里面去取了一下当前音量,绘制成了一个图,代码运行起来没问题,音量正确得取到了,并且也绘制在了图上面,乍一看发现没什么问题,等了十几秒再回头看,发现貌似时间过得很慢,明明感觉已经起码过了十秒,但是录音图上停留在6秒7秒的样子,很疑惑,在block中,加了一个Log,就看出缘由了。

设置的明明是0.05秒执行一次,但是从图中来看,基本都是隔了0.1秒才执行一次,很少有0.5秒才执行一次,NSTimerrunloop运行的时候执行,runloop中有其他任务时势必会影响到NSTimer,但是没想到影响会那么大,查阅了官方文档后发现,iOS设备中,NSTimer的精度在0.1秒左右,看来是需要另寻他法了。

平均误差:18.11

while循环

具体思路就是新开一个线程,然后跑一个while循环去检测,代码如下:

1
2
3
4
5
6
7
8
dispatch_queue_t timerQueue = dispatch_queue_create("timer", DISPATCH_QUEUE_SERIAL);

dispatch_async(timerQueue, ^{
while (YES) {
usleep( 50000 );
NSLog(@"timer");
};
});

平均误差:65.33毫秒

貌似比NSTimer还不靠谱,经过测试,发现精度基本上在200MS左右。

CADisplayLink的调用是根据屏幕刷新率来的,通常iOS设备的刷新率在60HZ,那么一秒钟就能够被调60次。

1
2
3
CADisplayLink *link = [CADisplayLink displayLinkWithTarget:self selector:@selector(testDisplayLink)];
link.paused = NO;
[link addToRunLoop:[NSRunLoop currentRunLoop] forMode:NSRunLoopCommonModes];

平均误差:0.002(在每秒60次调用的情况下)

相当精确,在固定刷新率下,精度基本在0.001毫秒的样子,但是测试发现CADisplayLink有以下问题:

  1. 时间间隔只能是 1 / 60,没法自定义,可以设置 1 / 60的倍数。
  2. 首先精度在 1 / 60秒的样子,无法再精确了,在此基础上,调用间隔非常精确。
  3. 全局只能有一个CADisplayLink实例在运行。
  4. 屏幕在熄灭状态下无法调用此方法。

dispatch_after

通过递归得调用这个函数来实现

1
2
3
4
5
6
7
8
9
dispatch_after(
dispatch_time(
DISPATCH_TIME_NOW,
(int)(0.05 * (double)NSEC_PER_SEC)
),
dispatch_get_main_queue(), ^{
NSLog(@"dispatch after");
[self after];
});

平均误差:12.17毫秒。

目前看来是误差最小的了,如果不算CADisplayLink的话。

官方提供的高精度解决方案

官网地址:https://developer.apple.com/library/content/technotes/tn2169/_index.html

平均误差:0.34毫秒

大概测试了一下,间隔在1毫秒的时候,误差在0.7毫秒,间隔在5毫秒的时候误差0.36,间隔在2毫秒的时候误差在0.7毫秒,间隔在4毫秒的时候误差为0.3毫秒,间隔在3毫秒的时候误差在0.49

间隔 误差
5ms 0.36ms
4ms 0.3ms
3ms 0.49ms
2ms 0.7ms
1ms 0.7ms

总体来说,这种方式的精度在5毫秒左右,5毫秒已经能满足绝大多数需求了。

获取最小有效像素区域

背景

最近在做一个需求是对页面某一块区域截图,本身没什么,但是发现需要截图的区域非常大,然后里面的内容通常只有一小点,这样我把整张图片拿出去就会发现只有中间一点点有东西,其他地方都是空白的,用户体验就会比较差了,记得PhotoShop里面有一个功能是能截取整张图片的最小有效像素区域,本来以为canvas自带有这种功能,没想到还是要自己实现的,虽然功能很简单,但是网上也没找着相应类库,可能是我搜索的姿势不对,或者这个需求实在太简单了,没人封装成类库了,虽然完全没做过图像处理,也开始找一些资料。

文字描述比较抽象,放上一张图片。

原始图片

处理后图片

canvas

网页内容是用Html2Canvas把DOM截图成了canvas,原理不是很清楚,大概是读取DOM样式然后在canvas里面重新画一遍,这不是重点,这个时候我们已经拿到画成了的canvas了,改怎么处理呢?

1
2
3
var canvas  = document.getElementById( 'canvas' );
var ctx = canvas.getContext( '2d' );
var imgData = ctx.getImageData( 0, 0, canvas.width, canvas.height );

通过getImageData方法就能够拿到canvas的原始像素点信息了,四个参数描述了一个矩形,返回的像素点信息就是这个矩形内的信息。

返回的是一个Uint8ClampedArrayUint8ClampedArrayJavaScript中的类型数组,具体的可以参看这里,简单来看就是这个数组中的元素都是unsigned int并且都是8位的,所以取值范围是0~255,非常适合用来存放ImageData,而且类型数组因为类型都是确定的,使用起来比普通数组速度上会快很多。

ImageData

拿到了原始的像素点数据,来看看数据长什么样子,首先看数组长度,假设是一个100X100的图片,那么理论上像素点的数量是100100,也就是10000个,拿到ImageData的数组看长度其实有40000,简单猜测一下大概是10000 rgba,看具体的数组内容很容易就能够验证我们的猜想。

一个典型的ImageData大概长这个样子,这是一个从左往右,从上到下按顺序排列的一个数组:

1
2
3
4
5
6
imageData = [ 
0, 0, 0, 0,
255, 255, 255, 1,
122, 122, 122, 0.5,
....
];

如何获取最小像素区域?

要实现这个功能的本质,其实就是如何寻找最上、最右、最下以及最左四个点(P1,P2,P3,P4),有了这四个点,我们就能画出一个最小矩形,把这个图像框在里面。

一维组数映射到二维空间

根据数组下标计算当前点在二维空间的坐标,方法其实很简单。

看下面这张图,可以认为这是一张 5X4 的一张图片,那么总共就有20个像素点,每个格子里的0-19数字就意味着这是在一维数组里面的下标N,由此可得

X = N % width;
Y = Math.floor( N / width );

pixel

全数组扫描

首先能想到的最简单的办法,就是循环整个数组,当alpha值大于0的时候,即可判断这个点是一个有效像素点,然后整个数组循环完成之后,即可知道得到P1,P2,P3,P4四个点。

以下是实现代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
function getEffectiveRect( imageData, width, height ) {
// 上点, Y值最小
var p1 = [ 0, height ];
// 右点, X值最大
var p2 = [ 0, 0 ];
// 下点, Y值最大
var p3 = [ 0, 0 ];
// 左点, X值最小
var p4 = [ width, 0 ];

var data = imageData.data;
var number = 0;
console.time( 'image1' );
for ( var i = 0; i < data.length; i ) {
number++;
var r = data[ i++ ];
var g = data[ i++ ];
var b = data[ i++ ];
var a = data[ i++ ];
if ( a === 0 ) {
continue;
}
var x = number % width;
var y = ( number - x ) / width;
if ( p1[ 1 ] > y ) {
p1[ 0 ] = x;
p1[ 1 ] = y;
}

if ( p2[ 0 ] < x ) {
p2[ 0 ] = x;
p2[ 1 ] = y;
}

if ( p3[ 1 ] < y ) {
p3[ 0 ] = x;
p3[ 1 ] = y;
}

if ( p4[ 0 ] > x ) {
p4[ 0 ] = x;
p4[ 1 ] = y;
}
}

return { p1, p2, p3, p4 };
}

从矩形的四个角分别去寻找

具体的思路如下图:

from-rect

理论上只要有效像素区域越大,那么需要计算的量就越少,速度也就越快,以下是我的实现代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
function getEffectiveRect2( imageData, width, height ) {
var data = imageData.data;

console.time( 'image2' );
var p1 = [];
var p2 = [];
var p3 = [];
var p4 = [];

// 寻找P1
var len = 0
for ( var i = 0; i < data.length / 4; i ++ ) {
len += 7;
var a = data[ len ];
if ( a !== 0 ) {
var x = i % width;
var y = ( i - x ) / width;
p1 = [ x, y ];
break;
}
};

//寻找P3
var len = 0;
for ( var i = data.length / 4 - 1; i >= 0; i -- ) {
len += 7;
var a = data[ len ];
if ( a !== 0 ) {
var x = i % width;
var y = ( i - x ) / width;
p3 = [ x, y ];
break;
}
}

// 寻找P2
for ( var i = width - 1; i >= 0; i -- ) {
if ( p2.length ) {
break;
}
var a = p1[ 1 ];
var b = p3[ 1 ];
var len = i + a * width;
for ( var j = a; j < b; j ++ ) {
len += width;
// var point = i + j * width;
var point = len;
var a = data[ point * 4 + 3 ];
if ( a !== 0 ) {
var x = point % width;
var y = ( point - x ) / height;
p2 = [ x, y ];
break;
}
}
};

// 寻找P4
for ( var i = 0; i < width; i ++ ) {
if ( p4.length ) {
break;
}
for ( var j = p1[ 1 ]; j < p3[ 1 ]; j ++ ) {
var point = i + j * width;
var a = data[ point * 4 + 3 ];
if ( a !== 0 ) {
var x = point % width;
var y = ( point - x ) / height;
p4 = [ x, y ];
break;
}
}
}

return { p1, p2, p3, p4 };
}

将两种方法分别去计算最开头的图片,两种方式的计算时间分别是:

1
2
image1: 10.83ms
image2: 15.32ms

结论

方法2引入的复杂的逻辑反而降低了计算时间,逻辑最简单的全数组扫描比第二种方式快了30%,因为涉及到的东西比较多,目前还不清楚到底是什么原因导致第二种方式速度如此慢,测试了一下,只有当一整张图片几乎全部有像素点的时候,两种方法的速度才差不多。后续我会继续寻找原因,猜测可能是方法2的计算量比方法1多,而方法1更多的是比较和赋值。