SSH建立通道的过程和认证原理

我们每天都在用以下SSH命令连接服务器,可是这个命令的背后究竟发生了什么呢? SSH协议是为何被称为 安全的shell 呢?

1
$ ssh root@xxx.xxx.com

在学习SSH原理的过程中,发现阮一峰的 SSH原理与运用(一):远程登录 对SSH真实的建立通道和认证过程还是讲的有些错误的,所以我重新查阅了网络资料,对SSH建立连接的过程进行了整理,因此有了本文。

SSH协议采用C-S(客户端-服务器端)架构进行双方的身份验证以及数据的加密。服务器端组件监听指定的端口,负责安全连接的建立、对连接方的身份认证、以及为通过身份认证的用户建立正确的环境。

一个SSH会话的建立过程分为两个阶段。

  • 第一阶段,双方沟通并同意建立一个加密连接通道以供后续信息传输用。
  • 第二阶段,对请求接入的用户进行身份验证以确定服务器端是否要给该用户开放访问权限。

共同建立加密通道

当客户端发起TCP连接时,服务器端返回信息说明自己支持的协议版本,如果客户端上支持的协议与之匹配,则连接继续。服务器会提供自己的公共主机密钥(public host key)以让客户端确认自己访问的是正确的机器。例如你通常第一次与服务器建立SSH连接,会看到类似这样的提示:

1
2
3
The authenticity of host 'github.com (13.250.177.223)' can't be established.
RSA key fingerprint is SHA256:nThbg6kXUpJWGl7E1IGOCspRomTxdCARLviKw6E5SY8.
Are you sure you want to continue connecting (yes/no)?

这是在让你确认服务器的公钥指纹。然后,双方采用一种Diffie-Hellman算法共同为该会话建立密钥。每一方的一部分私有数据加上来自对方的一部分公共数据,通过这种算法计算,能够得出完全相同的密钥用于本次会话。整个会话的通讯内容都使用该密钥进行加密。(小提示: Diffie-Hellman这个阶段使用的 公钥/私钥对 与后文用户验证身份阶段所用的SSH密钥是完全无关的)

经典Diffie-Hellman算法的计算步骤如下:

  1. 双方共同选择一个大值素数作为种子值(seed value)
  2. 双方共同选择一个加密生成器(通常是AES),用于后续的数值操作
  3. 双方分别各自选择一个素数,该素数的值对对方保密,用于生成本次通讯的私钥(与SSH身份认证私钥无关)
  4. 双方分别用各自的私钥、共同的加密生成器、和共同的素数生成各自的公钥
  5. 双方将各自的公钥共享给对方
  6. 双方用各自的私钥和对方发过来的公钥生成另一个密钥。根据该算法,双方各自计算出来的两个密钥是完全一样的,即“共同的秘密”
  7. 该密钥被用于本次通讯所有内容的加密
  8. 这个共享密钥的加密方式被称为二进制数据包协议(binary packet protocol)。该过程能够让双方平等的参与密钥生成的过程,而不是由单方掌握。这种共享密钥生成的过程是安全的,双方没有交换过任何未经加密的信息。

最终生成的密钥是 对称式密钥,一方用于加密信息的密钥等同于另一方用于解密信息的密钥,而任何第三方由于不持有该密钥,是无法解密双方传递的内容的。

以上 会话加密通道 建立后,SSH才开始进入用户认证阶段

服务器认证用户以开放访问权限

服务器验证用户身份以决定是否准许其访问。验证有不同的方式,选择的验证方式取决于服务器的支持。

SSH认证有2种方式:

  1. 密码验证。服务器要求客户端输入密码,客户端输入的密码经过上述的通道加密传输给服务器。虽然密码是加密过的,然而该方法仍然不被推荐,因为用户经常为了省事而使用过于简单的密码,而这类密码很容易就能够被自动化脚本破解。
  2. SSH秘钥验证。最流行的验证方式是 SSH密钥对,这也是当前最推荐的方式。

SSH密钥对 是非对称密钥,私钥和公钥分别用于不同的功能。公钥用于加密,而私钥用于解密。公钥可以随意上传、共享,因为公钥的流通并不会危及到私钥的保密性。SSH密钥对的验证过程起始于上一部分加密通道建立之后,其具体执行步骤如下:

  1. 客户端发送自己的密钥ID给服务器端
  2. 服务器在自己的 authorized_keys 文件中检查是否有此ID的公钥
  3. 如果有,则服务器生成一个随机数,用该公钥加密之
  4. 服务器将加密后的随机数发给客户端
  5. 客户端用私钥解密该随机数,然后在本地为随机数做MD5哈希
  6. 客户端将该MD5哈希发给服务器端
  7. 服务器端为一开始自己生成的随机数也做一个MD5哈希,然后用通讯通道“公共的密钥”将该哈希加密,再跟客户端发来的内容进行对比。如果双方内容一致,则通过验证,开放访问权限给客户端

简单来说,服务器端发现这个客户端想要使用authorized_keys里已经存在的公钥直接免密登录,那么就要证明这个客户端是这个公钥的主人。所以它使用这个公钥去加密随机信息发给客户端,客户端用私钥解密信息(解密后再把信息md5且私钥加密后传给服务器校验)从而证明自己持有私钥。该过程同时使用了对称加密和非对称加密,两种方式各有自己的功用,实际上这个过程就是在使用签名机制来认证客户端。

防止公钥劫持

在建立加密通道的阶段,ssh协议需要服务端首先把公钥传递给客户端,那ssh协议里面是如何保证服务器传输公钥时没有被劫持的呢(即中间人冒充服务器,在中间冒充服务器,给客户端传输了自己的公钥)?

答案是没有办法。。。 其实由于ssh协议由于没有采用https的证书机制,这里并没有很好的办法去校验这个首次传输的公钥是否是真实的目标服务器传来的。但你的客户端收到这个公钥时一般会给你展示他的指纹。 比如在ssh连接github上的仓库时,你可以检查服务器传来的签名是否与github网站公布的 github服务器公钥指纹 一致。 这里系统会出现一句提示:

1
2
3
4
Cloning into 'fet'...
The authenticity of host 'github.com (13.250.177.223)' can't be established.
RSA key fingerprint is SHA256:nThbg6kXUpJWGl7E1IGOCspRomTxdCARLviKw6E5SY8.
Are you sure you want to continue connecting (yes/no)?

提示中的 SHA256:nThbg6kXUpJWGl7E1IGOCspRomTxdCARLviKw6E5SY8. 正是github官网公布出来的公钥指纹,一致的话你才继续进行登录。 输入yes出现:

1
Warning: Permanently added 'github.com' (RSA) to the list of known hosts.

表示主机已经被得到认可. 对于非免密情况下的ssh登录来说,可能还需要你输入密码

1
Password: (enter password)

当远程主机的公钥被接受以后,它就会被保存在文件 $HOME/.ssh/known_hosts 之中。下次再连接这台主机,系统就会认出它的公钥已经保存在本地并已经被认可了,从而跳过警告部分. ~/.ssh/knows_hosts里面确实是记录了已经被认可过的公钥:

1
github.com ssh-rsa AAAAB3NzaC1yc2EAAAABIwAAAQEAq2A7hRGmdnm9tUDbO9IDSwBK6TbQa+PXYPCPy6rbTrTtw7PHkccKrpp0yVhp5HdEIcKr6pLlVDBfOLX9QUsyCOV0wzfjIJNlGEYsdlLJizHhbn2mUjvSAHQqZETYP81eFzLQNnPHt4EVVUh7VfDESU84KezmD5QlWpXLmvU31/yMf+Se8xhHTvKSCZIFImWwoG6mbUoWf9nzpIoaSjB+weqqUUmpaaasXVal72J+UX2B+2RPW3RcT0eOzQgqlJL3RKrTJvdsjE3JEAvGq3lGHSZXy28G3skua2SmVi/w4yCE6gbODqnTWlg7+wC604ydGXA8VJiS5ap43JXiUFFAaQ==

公钥指纹

在你的客户机可以这样查看本机的RSA公钥指纹:

1
ssh-keygen -E md5 -l -f your_public_key.pub

当然也可以自己去计算 ~/.ssh/id_rsa.pub 里面公钥的指纹/摘要, 下面是使用Node.js去实现获取公钥指纹的方法:

Node.js计算公钥指纹

上文讲到,ssh首次连接远程服务器时会提示你服务器的公钥指纹,从而让你肉眼确认服务器的真实性(因为这里没有证书机制,所以只能靠肉眼来确认对方身份了)。那么这个RSA公钥的指纹是怎么算出来的呢?

其实他的原理毕竟简单,就是对公钥进行MD5加密或者sha256加密得到的哈希摘要而已。
手工计算也很简单:

  1. 首先要知道我们在 ~/.ssh/id_rsa.pub~/.ssh/known_hosts 里面看到的公钥是已经base64编码过的了。所以第一步是对base64进行反编码

    1
    var foo = Buffer.from('AAAAB3NzaC1yc2EAAAADAQABAAACAQDIVNrzTViuXOaaHARAIHTgXadYLEWSS9GwCkSXsYVFn3AWH75eKYNLM05ERPDc/wc+fKE44ihHVzlqWLlTksO9gInJ3vcRmj3h/e3G9SNaeJwRC1jPpw8UpwjSAgcFleO1GRdkzPoum6Y5M+CfXfbv5pII5bTAgCu54I4xlEvNnCY4vX0K+6F8ckO5VMuAcRSMwEOSGRcPuAbJwaSWk5lX2x7O5mDRImCMTIFKVDEX/DBThDN1iOm3ZWMoiZzjp2VhO7F7jNxiS7N3rbe08YPaD20Ym8wGcsXcJNX5y08hb6Z77NTcADsR1mOHdhSAcG3NaIuSO95RDMI8BkJlHnqOqC8R2yG0ltcYjjGeVQKnI+bL+tMCzr1TbrU6/qPdi4jrXk25blzUT41o7j1wl1z5fZK1056V7qVdHzHPAK2iDD4fEx+STePPNAb/e0fccW7xqHe8ynoyE4pIMkr4o2RoEqvjPWzUkc9f83wyuL24LE+vmsur0LXw3kBAGoCv4GxuJ11VdYYUHHBMJqOU7c04A1ekIuwHigeVyy6fpIAvo/DSsqTIQP+2WEt/yDH+3wor94MjiXpVK9bie7Y5y6wVuRta6UCSkk/3CIUam0b9h5r1HAb2j2K9RB0UM1T1Z15OA6V++ejaBgsgh20lsmn/ZoKTnGzuHicJcsXYvsCREQ==', 'base64')
  2. 对其进行md5或sha256的加密:

    1
    2
    3
    4
    5
    6
    // github上设置里面看到的是客户机公钥的md5指纹
    var md5 = crypto.createHash('md5')
    md5.update(foo).digest('hex')
    // 连接服务器时,shell里提示的是服务器公钥的sha256指纹base64编码值
    var sha256 = crypto.createHash('sha256')
    sha256.update(foo).digest('base64')

refer

理解SSH的加密与连接过程
SSH原理与运用(一):远程登录