实战:150行Go实现高性能加密隧道
1. 质疑
上篇《实战:150行Go实现高性能socks5代理》发出来后,有同学提出了一些问题,比如说测试机配置太高,结果“不太具有说服力”、“是在耍赖”,再比如说应该和其他开源 socks 代理对比才比较有说服力。
这些质疑我觉得都非常有道理,经过深刻的反思,我做出一个艰难的决定,那就是不予理会,毕竟有这时间,我还不如另写一篇更有营养的,比如在这篇里,我们将看到,如何使用 150 行 Go 实现一个高性能的加密隧道。
不过有一个质疑值得专门一提:@hjc4869 大佬指出,由于 tcp 是双工通信,而 Socks5Forward 在某个方向结束后就把 src 和 dest 都关闭,不符合 tcp 规范,无法支持 half-closed connection。
这确实是个问题,好在依赖这个特性的场景不多,而且有些网络节点(如部分 NAT 路由器)本身并未完整实现这个特性(遇到fin直接或延迟关闭,可避免一些DoS攻击),因此该特性在实践中并不够可靠;此外,完整实现这个特性,代码会比较啰嗦,所以为了标题的 flag 暂且妥协,感兴趣的同学可以自己试着完善它(提示:可以抄一下 io.Copy 的源码)。
2. 隧道
为了照顾新来的同学,我们可能还应该先介绍一下什么是隧道。
如下图所示,直接访问目标服务时,由于网络上可能存在不安全因素(窃听等),我们会希望采用一个隧道协议,将需要传输的内容封装在协议的负载中,从而保障通信的安全。
一个典型的隧道协议就是 SSL/TLS,通过将 http 封装在 TLS 隧道中,我们就得到了 https,同样我们还可以有 ftps,socks5-over-tls;应用隧道的其他场景还包括需要在不兼容的网络上传输数据等情况。
上图中的“加密设备”并不一定需要是个独立的硬件,在接下来的内容里,我们会看到如何实现一个软件版本。
3. 开挖
饭要一口一口吃,隧道要一点点挖。
所以我们先搞个不加密的、用于传输一个 TCP Stream 的隧道,比如下图所示,将请求先发给中继 A(IP_A:PORT_A),A 转发给 B (IP_B:PORT_B),再由 B 转发到目标节点(IP:PORT)。
对于中继A,实现起来就非常简单了,27行搞定:
func main() {
listenAddr := "IP_A:PORT_A"
remoteAddr := "IP_B:PORT_B"
server, err := net.Listen("tcp", listenAddr)
if err != nil {
fmt.Printf("Listen failed: %v\n", err)
return
}
for {
client, err := server.Accept()
if err != nil {
fmt.Printf("Accept failed: %v", err)
continue
}
go Relay(client, remoteAddr)
}
}
func Relay(client net.Conn, remoteAddr string) {
remote, err := net.Dial("tcp", remoteAddr)
if err != nil {
client.Close()
return
}
Socks5Forward(client, remote)
}
注:这里的 Socks5Forward 借用了上篇的实现。
而中继B的实现就更简单了:由于它和A实际上做了相同的工作,只是收发的地址不同,因此将 listenAddr、remoteAddr 分别改成 "IP_B:PORT_B"、"IP:PORT" 就完工了。
为了方便使用,我们可以通过 flag 包,从命令行参数里读取这俩变量:
listenAddr := flag.String("listenAddr", "127.0.0.1:2000", "")
remoteAddr := flag.String("remoteAddr", "127.0.0.1:2001", "")
flag.Parse()
注:flag.String 返回的是 *string,因此后面引用的地方也需相应修改(dereference)。
3. 加密
隧道挖起来好像比想象中容易,咱们再来看看加密怎么搞。
如下图所示,原来的中继A、B不能只是简单地转发报文了 —— 它们应当在写入隧道前进行加密,从隧道读出时进行解密。
也就是说,对于中继 A,remote 需要加/解密,而对于中继 B,则是 client 需要加/解密。
对于熟读 GoF 的同学,应该很容易就能想到,这里可以用一个代理模式(Proxy Pattern)来完成加解密的工作。
由于 net.Conn 本身是一个 interface,我们可以基于这个 interface,把 client/remote 封装起来,实现一个带加密的类型;考虑到 Socks5Forward 里面只用到 Read, Write, Close 这三个方法,我们可以进一步简化成这么一个 interface:
type CipherStream interface {
Read(p []byte) (int, error)
Write(p []byte) (int, error)
Close() error
}
然后我们只需要实现一个 XXXCipherStream,分别在 Write 里做加密、 Read 里做解密就好了。
看看新版的 Relay 方法可能更容易理解:
func Relay(client net.Conn, remoteAddr string, role string) {
remote, err := net.Dial("tcp", remoteAddr)
if err != nil {
client.Close()
return
}
var src, dst CipherStream
if role == "A" {
src = client
dst, err = NewXXXStream(remote)
} else {
src, err = NewXXXStream(client)
dst = remote
}
if err != nil {
src.Close()
dst.Close()
return
}
Socks5Forward(src, dst)
}
注:role 可在启动时通过命令行指定,取值为A或B。
4. 加密²
是不是简单到想马上写一个 AESCipherStream ?
别急,AES 作为一个块加密(Block Cipher)算法[1],并不太适合用在这里:它的一个 block 是 16 字节,这意味着即使原始数据只有一个字节(比如 ssh 时的每一次按键),也需要实际传输 16 字节;在具体实现中还会遇到一些琐碎的细节(不信你试试)。
实际上,对于 TCP Stream 这种流式传输的场景,更适合的是流式加密(Stream Cipher)算法[2]。
比如说小明要给小萌发送整整 1024 字节的信息,他们事先约定了一个 1024 字节的密钥 k ,那么小明可以把明文 p[0..1023] 和 k[0..1023] 逐个字节异或得到密文 c[0..1023](加密),小萌收到 c 以后,将 c 和 k 再逐字节异或就能得到 萌 明文(解密)。
如果双方每次通信都能够约定一个不短于传输信息的密钥(一次一密),就能解决香农(对,就是信息论创始人Shannon)提出的“完善保密性” —— 但很遗憾,实际操作中往往做不到。
所以更常见的做法是由一个较短的数据(比如一个 256 bit 的密钥)通过一定的算法生成无限长的密钥流;具体实现中还应当引入一定随机性,否则相同的明文(比如http请求通常总是 GET 或 POST打头)总是生成相同的密文,可能会大幅降低破译密文的难度(频率分析法),并且还可能遭受重放攻击。
我们当然可以基于以上这些朴素的想法立即实现一个简单的加解密算法,不过密码学那么多的坑我们就不用一个一个去踩了,毕竟 Google 已经在 RFC 7539 中为我们提供了 chacha20 加密算法,而且 golang 里就有现成的实现[3]。
chacha20 的基本用法是:
(a) New 一个 Cipher 对象
key 是双方共享的一个 32 字节密钥
nonce 是随机生成的 24 个字节,应当由加密方(encoder)生成,并通过 明文 发送到接收方,用于创建 decoder
cipher, err := NewUnauthenticatedCipher(key, nonce)
(b) 调用 cipher.XORKeyStream 将 src 加/解密到 dst 里
cipher.XORKeyStream(dst, src []byte)
注:因为使用的 XOR,所以加、解密实际上共用同一段代码逻辑。
5. 加密³
铺垫完了,终于可以添加一些细节了。
我们先搞一个 Chacha20Stream 类型:
type Chacha20Stream struct {
key []byte
encoder *chacha20.Cipher
decoder *chacha20.Cipher
conn net.Conn
}
然后写一个 New 方法来创建对象:
随机生成 nonce
创建 encoder
将 nonce 发送给对方,用于创建 decoder
func NewChacha20Stream(key []byte, conn net.Conn) (*Chacha20Stream, error) {
s := &Chacha20Stream{
key: key, // should be exactly 32 bytes
conn: conn,
}
var err error
nonce := make([]byte, chacha20.NonceSizeX)
if _, err := rand.Read(nonce); err != nil {
return nil, err
}
s.encoder, err = chacha20.NewUnauthenticatedCipher(s.key, nonce)
if err != nil {
return nil, err
}
if n, err := s.conn.Write(nonce); err != nil || n != len(nonce) {
return nil, errors.New("write nonce failed: " + err.Error())
}
return s, nil
}
接着是 Read 方法:首次被调用时应当先读出 nonce、创建 decoder,然后再读取加密数据:
func (s *Chacha20Stream) Read(p []byte) (int, error) {
if s.decoder == nil {
nonce := make([]byte, chacha20.NonceSizeX)
if n, err := io.ReadAtLeast(s.conn, nonce, len(nonce)); err != nil || n != len(nonce) {
return n, errors.New("can't read nonce from stream: " + err.Error())
}
decoder, err := chacha20.NewUnauthenticatedCipher(s.key, nonce)
if err != nil {
return 0, errors.New("generate decoder failed: " + err.Error())
}
s.decoder = decoder
}
n, err := s.conn.Read(p)
if err != nil || n == 0 {
return n, err
}
dst := make([]byte, n)
pn := p[:n]
s.decoder.XORKeyStream(dst, pn)
copy(pn, dst)
return n, nil
}
剩下的 Write 和 Close 方法就简单了:
func (s *Chacha20Stream) Write(p []byte) (int, error) {
dst := make([]byte, len(p))
s.encoder.XORKeyStream(dst, p)
return s.conn.Write(dst)
}
func (s *Chacha20Stream) Close() error {
return s.conn.Close()
}
最后把上面几段代码组装起来,补充相关 import 等,就是一个可以跑的加密隧道了,完整代码参见这个 gist:tunnel.go[4]。
6. 燥起来
废话不多说,跑起来瞧瞧。
启动A:
$ go run tunnel.go -role A -secret xxx
[127.0.0.1:2000] -> [127.0.0.1:2001], role = A, secret = xxx
启动B:
$ go run tunnel.go -role B -secret xxx \
-listenAddr 127.0.0.1:2001 \
-remoteAddr job.toutiao.com:80
[127.0.0.1:2001] -> [job.toutiao.com:80], role = B, secret = xxx
试着发个 GET 请求,输入头两行,看看响应:
$ nc 127.0.0.1 2000
GET /s/JxLbWby HTTP/1.1
Host: job.toutiao.com
HTTP/1.1 301 Moved Permanently
Content-Type: text/html
Content-Length: 178
...(省略其他header)...
Location: https://job.toutiao.com/s/JxLbWby
301 Moved Permanently
301 Moved Permanently
nginx
注:↑ Location 里给出的 url 推荐在浏览器中打开查看。
(๑•̀ㅂ•́)و✧ 完美!
代码写完了,那么性能怎么样呢?懒得测了,反正肯定很好。
感兴趣的同学可以自己试试,比如把上篇的 socks5 代理作为 B 的 remoteAddr,就可以沿用上一篇的压测流程。
诶?好像发现了一种奇怪的用法。不过请注意,切勿滥用上述方案,否则可能会违反《中华人民共和国计算机信息网络国际联网管理暂行规定》第六条、第十四条之规定,后果自负。
7. 小结
又该收尾了,照例做个小结:
隧道可以用于解决通信安全、协议兼容等场景;
块加密算法(如AES)更适合文件加密等场景;
流式加密算法(如chacha20)更合适流式传输场景;
加密隧道和socks5代理组合起来有可能违法,请勿滥用。
那么,在祖国的大地上,有没有既可以不违法、又能够跨越长城走向世界的办法呢?
(中国第一封电子邮件的内容;图:QQ邮箱)
可别说,还真有 —— 工信部发言人在2019年9月20日表示[5],跨国公司因自己办公的需要,需要用专线的方式开展跨境联网时,可以向经电信主管部门批准,任何合法的使用均受到法律保护。
比如字节跳动,为了建设21世纪数字丝绸之路,通过技术出海,在40多个国家和地区排在应用商店总榜前列,包括韩国、印尼、马来西亚、俄罗斯、土耳其等“一带一路”沿线的主要国家。
参考资料:
1. wikipedia - 分组密码(块加密)
https://zh.wikipedia.org/wiki/分组密码
2. wikipedia - 流密码
https://zh.wikipedia.org/wiki/流密码
3. chacha20
https://godoc.org/golang.org/x/crypto/chacha20
4. tunnel.go
https://gist.github.com/felix021/c1c613abf31a42322b28e1b7bb1407f0
5. 工信部:VPN规定不会影响国内外企业合规开展跨境业务
https://www.sohu.com/a/342217933_115479
推荐阅读