设计安全协议前,请先思考一个问题,设计安全协议的目地是什么,为什么需要一个独立的安全协议。如果经过分析,你的答案是,需要一个全功能的安全协议。请转回头去,使用TLS。设计安全协议前,请务必问过自己上面这个问题,并在过程中牢记于心。在设计过程中,经常会有的一种现象是,觉得协议已经实现了自己所需的基本功能,跳一下就能实现一些更酷的功能。这时候,请退出来,再看一遍最上面的话。如果TLS已经变得更适合的话,你前面的工作就是沉没成本,不值一提。因此,请在你自己的协议里专注于解决你自己的问题。

安全协议的基础构成

安全协议最小有两个目标,安全性和可靠性。安全性是说数据不会泄漏,可靠性是说数据不可更改。如果只考虑安全性,最简单的实现是对数据流进行加密。再考虑可靠性的话,可能需要内容验证算法。这是一个安全协议的最简构成。脱离这个构成的话,协议就很难讲是一个“安全协议”。

加密算法

加密算法包括两部分,算法和模式。我们先说算法,再说模式。

算法可选的其实不多。主流的算法中,DES/3DES已经实际废弃,而且跑的比AES还慢。实际可以选的只有AES(128/192/256)和chacha20/xchacha20两种。建议都支持,不会太麻烦的。一种适用于CPU支持AESNI的场合,另一种相反。

再说模式。对流进行加密的最简单办法就是流式加密算法,最出名的就是CFB/OFB/CTR这三个。

不幸的是,流式算法没有验证,对篡改的抵抗力比较差,容易受到KPA/CCA/CPA这类攻击的影响。如果只是用于简单加密场合还凑合,对于比较严肃的场合来说就不是很严肃了。

然后有什么选项?CCM模式很出名。但从密码学建议来说,AEAD应该是当今首选。

AEAD要注意一个问题。道理上说,每次使用的nonce都需要不同。因此如何设计nonce就是个很有技巧的事。这篇文章简介了TLS各个版本的nonce方案。对TCP来说,TLS1.3的方案是最合适的。TCP不会发生乱序,因此序号是可推测的。对UDP来说,只能选择TLS1.2的方案。注意如果选择发送nonce的话,4字节的空间只有2^32。按照1000Mbps,一个报文1K算一下。只需要9.5小时就会发生回转。因此序列增长的nonce长度不能小于8字节。随机nonce来说,12bytes的nonce只能取2^32次,大致保证不发生碰撞。所以随机nonce应该至少24bytes。(也就只有xchacha20可选了)

压缩

速度先不论,加密协议本身最好不要压缩。

如果在加密前压缩,选择内容就可能影响压缩结果或压缩速度,造成侧信道攻击。如果在加密后压缩,原则上就应该没有压缩空间了。如果一个加密算法在完成加密后还能大幅压缩,说明这个算法大概率出了问题。因此,最好不要在协议内设计压缩。

隐藏头部

AEAD+record的设计有个比较麻烦的问题。AEAD的nonce是暴露在外的。使用随机nonce还好说。如果使用序列,那么nonce就成为一个明显的record分隔提示。如果头部中又明文包含了长度,这个问题会更加明显。

要对此进行防护,最简单的办法就是对头部再来一个AES128。16bytes足够覆盖8bytes头部+8bytes nonce。这个方法挫了点,但是好在这个AES不用做安全防御,只要保证头部无特征就好。

shared secret

以上算法的诸多参数中,有一些是可以通过发送协商的,另一些则不能。例如加密算法的key,是不能通过网络发送的。iv一般也不行。nonce可以通过网络发送,但建议双方对通过网络传输的nonce进行再处理(例如xor上相同的数据)。这些不能通过网络发送的内容,就叫做shared secret。而如何解决shared secret,就是安全协议的重头戏。

最简单的方法叫PSK(Pre-Shared Key)。通过预设的Key来通讯。这个方法的好处是简单,坏处是不保证前向安全性。所谓前向安全性,是说,如果攻击者保留了你的通讯数据,在未来key泄露的情况下,是否能解出你当时的通讯数据?很显然,PSK一旦获得key,就可以解开过去所有的通讯。

握手

为了保证通讯安全,建议所有shared secret都现场协商解决。这类协商算法,叫做Key eXchange算法,简称Kx。

Kx类算法实际上主要就两类,DH和ECDH。我偏好ECDH,长度比较短一点。其中偏好25519这条曲线,不解释。

KDF

ECDH获得的secret一般不长,25519为例,255bits。上面secrets最长可以吃掉60bytes数据,因此有两类方法。握手加宽和KDF。

所谓握手加宽,是将握手数据的宽度加倍,就如同在一个过程里同时做两个独立的Kx算法。最后得到的有效secrets长度就是翻倍的。

对于大多数情况来说,握手加宽没有必要(尽管不增加RTT,只增加报文长度)。256bits高质量随机数已经是个无法枚举的强度,用KDF展开就行。KDF保证即便一个key泄漏,也不会导致所有key一起出问题。当然,KDF的另一个目标——保护原始seed,对我们来说意义不大。因为我们的原始seed是握手结果,是临时的。

另外,KFD有两大类。hkdf和pbkdf2。对于seed质量足够高的情况来说,hkdf已经够用。pbkdf2多次循环,除了增加数据离散度外,另一重目标是减慢从seed到key的速度,使得穷举类攻击的成本变高。这个更针对人类输入的密码,对我们意义不大。

MITM

握手一个绕不开的问题就是MITM防御。MITM防御的基本思路是认证。常见有这么几种方案。

  1. 双方share key,发送方使用psk对kx请求数据做HMAC签署。
  2. 一方有多个用户名/key,另一方拥有一个用户名/key。基本过程同1,请求时带上用户名。
  3. 一方拥有另一方公钥。私钥签署,公钥认证。

对于服务器来说,1是不能选择的。服务器不能让所有客户共享一样的shared key。原因很简单。你也许能保证自己不泄漏PSK(也许这点都很难保证)。但是你一定不能保证所有其他用户都不会泄漏PSK。一旦PSK泄漏,攻击者就可以对所有用户实施MITM。

2的话,做客户端验证还可以,做服务器验证就比较罗嗦了。道理上说,双向的PSK最好不同。所以服务器和客户端同时选用方案2会造成一个用户两个密码。因此我会选择服务器用3做验证,客户端用2做验证。而非对称签署算法要快速安全密钥短的话,ECDSA是首选。ECDSA pubkey,在TLS中能够预植入CA。在我们自己的体系里,只能PSK。但是这个PSK是不怕泄漏的。

当然,客户端认证并不一定需要在安全协议里做。如果保持协议的精简性的话,这部分可以留给上层协议。届时可以使用challenge-response或time-sign做。不过在上层协议没有内置考虑的情况下,客户端认证会多出一个RTT来。所以这点请自行权衡。

握手隐藏

握手报文也有一定格式,因此也需要做隐藏。当然,靠谱的方法同样是AEAD。但是这次AEAD无法用AES来覆盖头部,因为反而可能加速暴露PSK。因此这里我们选择发送全部nonce。因此,如果我们选用12bytes的nonce,照理说我们只能握手2^32次。虽然说握手这么多次也是挺不容易的,但我们没必要折腾自己。选用xchacha20-ploy1305的话,可以使用24bytes的nonce。照理说是永不碰撞的。

另一个小技巧是,可以用上面的ECDSA pubkey作为PSK。首先,这个值双方一定都有。其次,足够随机。第三,重要性不高。即使被攻击出来,最多使得协议可被侦测,还是不能发起MITM解出协议内容。

一定不要做的事

比起怎么做,一定不要做更重要。

  1. 协议兼容,算法协商。这个对大规模部署是个必需品,但是对我们来说一定不能做。如果你已经在折腾这俩了,请再看本文第一段,然后考虑要不要用回TLS。
  2. 可插拔机制。即上面的那堆AEAD啦,KDF啦,Kx啦,可更换。实际上这个功能没啥用,最多内容的AEAD算法可更换,用来适配CPU。其余算法都是没得选的。Kx算法想短,只能用ECC类。再扔掉NIST的几个曲线,基本只有X25519。ECC签署同样也只有ed25519可用。HMAC靠谱又短的只有HMAC-SHA256和HMAC-SHA512-256。安全性和速度都是后者好,直接选后者就行。Kx的AEAD只有xchacha20可选,其余都有nonce碰撞的困扰。record隐藏头部的block算法,实际上只有AES可选。chacha20依赖nonce,又会绕回来。DES/3DES又不靠谱。这里千万别看TLS有上百种算法可以选,眼馋,觉得跳一下能够上。跳了,就不如用回TLS了。
  3. 签署链。同样,对大规模部署是个必需品,对我们来说一定不能碰。你自己想想,你部署服务的时候用过中间证书么?不都是ca直接签客户证书完事了。没OU,中间证书就用不到。更进一步说,没有同中心授权的多服务器,连ca都用不到。因为上面非对称签署只出现在服务器端认证这里。你自己装1,2,3,4,5几个机器。要么相信都不会漏,大家用同一个prikey。漏了大家一起死。要么相信隔离,然后给客户端的时候需要服务器1,给个域名给个pubkey。5个服务器5个pubkey。这俩都能接受,犯不着非搞个ca,然后给1,2,3,4,5一个个做sign。反正都是你自己,怎么?ca就不会漏么?如果你非要搞ca,不如跳回本文第一段再看一遍。