SSH(Secure Shell)是一种工作在应用层和传输层上的安全协议,能在非安全通道上建立安全通道。提供身份认证、密钥更新、数据校验、通道复用等功能,同时具有良好的可扩展性。本文从SSH的架构开始,教你纯手工打造SSH服务端,顺便再教你如何利用SSH服务端实现Git服务端协议。
目录
- SSH架构
- 建立传输层
- 交换版本信息
- 报文结构
- 算法
- 算法选择
- 密钥交换
- 密钥更新
- 使用算法
- 数据包封装
- 身份认证
- 使用连接层服务
- 实现Git服务端协议
- 打个广告
一、SSH架构
SSH 1.x协议已经过时,当前版本为2.0。主要由如下RFC文档描述:
- SSH Protocol Architecture (RFC 4251)
- SSH Transport Layer Protocol (RFC 4253)
- SSH Authentication Protocol (RFC 4252)
- SSH Connection Protocol (RFC 4254)
- SSH Assigned Numbers (RFC 4250)
另外还有若干RFC在上述基础上对协议进行扩展,本文主要对上述RFC内容进行介绍。建议上述文档按照从上至下的顺序阅读。最为麻烦的是SSH传输层协议,需要实现算法协商、交换密钥、数据加密、数据压缩、数据校验的算法。这部分的实现需要一定的算法功底,不过还好Fx帮我们实现了许多密码学算法,但是坑爹的是Fx并没有实现SSH所推荐的CTR工作模式。其中认证协议和连接协议作为SSH内置服务。认证协议提供了基于密码和基于密钥的身份认证方式。客户端不会无端的请求进行身份认证,每次身份认证都是为了请求某一服务的授权。但是前面也说了,当前SSH内置的两个服务分别是身份认证和连接协议,身份认证所请求授权的服务一定是链接协议。当然了,不能排除其他RFC会扩展出新的服务。
二、建立传输层
1. 交换版本信息
服务端默认监听22端口,建立TCP连接后客户端和服务端分别发送版本交换信息,格式为:SSH-protoversion-softwareversion SP comments CR LF
。其中协议版本必须为2.0,无论Windows还是Linux或是Mac,必须以CRLF结尾,包括换行符总长度不超过255字节。服务端在发送版本交换信息之前,可能会发送若干行不以SSH-
打头的欢迎信息,同样以CRLF作为换行符。版本交换信息不允许包含null
。版本交换信息的bytes作为Diffie-Hellman密钥交换的输入之一。RFC考虑了2.0协议如何兼容1.x协议,本文不做介绍。
2. 报文结构
SSH报文封装见下图,点击图片可以放大(图片来自wiki,如果看不到请自备*)。
协议实现过程中发现比较大的一个坑是RFC4251中定义的mpint数据类型,其表示长度可变的整数。当时没有严格的阅读定义就开始敲代码,结果导致有50%的概率密钥交换失败。就是因为没能正确的区分正负数的表示形式。
3. 算法
SSH主要由下列类型的算法作为基础:
-
压缩算法
- none (必须支持)
- zlib
- 加密算法
-
数据校验算法
- hmac-sha1 (必须支持)
- hmac-sha1-96 (推荐)
- hmac-md5
- hmac-md5-96
-
密钥交换算法
- diffie-hellman-group1-sha1 (必须支持)
- diffie-hellman-group14-sha1 (必须支持)
-
公钥认证算法
- ssh-dss (必须支持)
- ssh-rsa (必须支持)
必须支持的算法原则上需要实现。当然了,如果你肯定的知道对方支持哪些算法,可以偷懒不实现某些必须支持的算法。算法的具体实现请参考RFC文档中相关引用。
4. 算法选择
双方交换完版本信息后,接着发送所支持算法。报文格式为:先发送SSH_MSG_KEXINIT
作为报文标识,紧接着是16字节的随机数,接下来是10个name-list(定义见RFC4251)表示支持的算法,最后是first_kex_packet_follows
和一个uint32的0
。对于first_kex_packet_follows
,我表示这是蛋疼的参数,果断没有进行支持。具体格式如下:
byte SSH_MSG_KEXINIT
byte[16] cookie (random bytes)
name-list kex_algorithms
name-list server_host_key_algorithms
name-list encryption_algorithms_client_to_server
name-list encryption_algorithms_server_to_client
name-list mac_algorithms_client_to_server
name-list mac_algorithms_server_to_client
name-list compression_algorithms_client_to_server
name-list compression_algorithms_server_to_client
name-list languages_client_to_server
name-list languages_server_to_client
boolean first_kex_packet_follows
uint32 0 (reserved for future extension)
客户端和服务端的选择算法是一致的(废话,要不然双方怎么选择)。用一个字表示是:优先选择客户端靠前的算法。实现算法如下:
private string ChooseAlgorithm(string[] serverAlgorithms, string[] clientAlgorithms)
{
foreach (var client in clientAlgorithms)
foreach (var server in serverAlgorithms)
if (client == server)
return client;
}
5. 密钥交换
算法选择后,客户端发送SSH_MSG_KEXDH_INIT
数据包,发送Diffie-Hellman参数e
。服务端响应SSH_MSG_KEXDH_REPLY
回复参数K_S
、f
、hash(H)
。客户端验证回复参数后响应SSH_MSG_NEWKEYS
,之后服务端也响应SSH_MSG_NEWKEYS
,之后客户端与服务端使用新的密钥进行加密和校验数据。
按照Diffie-Hellman算法,客户端和服务端分别使用参数e
和f
计算出Shared Secret
,然后计算出Exchange Hash
,再进一步计算出客户端和服务端加密密钥、初始向量、消息签名密钥。第一次计算出的Exchange Hash
作为当次会话的Session Id
,作为会话的永久识别标识。
其中K_S
是服务端公钥,rsa和dss的序列化格式稍有差异。第一个字段是算法当前算法名称,接下来若干个mpint表示当前算法的公钥参数。
H
是当前能获取到的所有参数(包括噪音)的集合,包括了客户端和服务端版本标识、客户端和服务端SSH_MSG_KEXINIT
消息的载荷、服务端公钥、e
、f
、Shared Secret
。数据格式如下:
string V_C, the client's identification string (CR and LF excluded)
string V_S, the server's identification string (CR and LF excluded)
string I_C, the payload of the client's SSH_MSG_KEXINIT
string I_S, the payload of the server's SSH_MSG_KEXINIT
string K_S, the host key
mpint e, exchange value sent by the client
mpint f, exchange value sent by the server
mpint K, the shared secret
接下来是计算各种密钥,这部分用文字、用数学符号都不便表述,分还是用代码表述比较清晰。直接上代码:
var clientCipherIV = ComputeEncryptionKey(kexAlg, exchangeHash, clientCipher.BlockSize >> 3, sharedSecret, 'A');
var serverCipherIV = ComputeEncryptionKey(kexAlg, exchangeHash, serverCipher.BlockSize >> 3, sharedSecret, 'B');
var clientCipherKey = ComputeEncryptionKey(kexAlg, exchangeHash, clientCipher.KeySize >> 3, sharedSecret, 'C');
var serverCipherKey = ComputeEncryptionKey(kexAlg, exchangeHash, serverCipher.KeySize >> 3, sharedSecret, 'D');
var clientHmacKey = ComputeEncryptionKey(kexAlg, exchangeHash, clientHmac.KeySize >> 3, sharedSecret, 'E');
var serverHmacKey = ComputeEncryptionKey(kexAlg, exchangeHash, serverHmac.KeySize >> 3, sharedSecret, 'F');
其中
private byte[] ComputeEncryptionKey(KexAlgorithm kexAlg, byte[] exchangeHash, int blockSize, byte[] sharedSecret, char letter)
{
var keyBuffer = new byte[blockSize];
var keyBufferIndex = 0;
var currentHashLength = 0;
byte[] currentHash = null;
while (keyBufferIndex < blockSize)
{
using (var worker = new SshDataWorker())
{
worker.WriteMpint(sharedSecret);
worker.Write(exchangeHash);
if (currentHash == null)
{
worker.Write((byte)letter);
worker.Write(SessionId);
}
else
{
worker.Write(currentHash);
}
currentHash = kexAlg.ComputeHash(worker.ToByteArray());
}
currentHashLength = Math.Min(currentHash.Length, blockSize - keyBufferIndex);
Array.Copy(currentHash, 0, keyBuffer, keyBufferIndex, currentHashLength);
keyBufferIndex += currentHashLength;
}
return keyBuffer;
}
如果实在想看看文字描述求虐的,移步到RFC的Diffie-Hellman Key Exchange小结。
6. 密钥更新
SSH允许每个一段时间或传输一定量数据后,由任意一方再次发起密钥交换。再次密钥交换的过程与上述过程一致。无论是客户端还是服务端发起再次交换密钥的请求,原客户端和服务端的角色不改变。密钥更新过程中除了密钥交换的数据包,别的数据包都禁止发送。再次密钥交换是以SSH_MSG_KEXINIT
开始,SSH_MSG_NEWKEYS
结束。密钥更新继续沿用旧的向量(加密密钥、初始向量、消息签名密钥),密钥交换后更新所有的向量。密钥更新过程可以改变服务端密钥、算法等,唯独Session Id
不会更新。
7. 使用算法
算法选择和密钥交换后,客户端和服务端要开始使用所选择的算法了。正如报文封装图所示,发送数据包要进行压缩、填充、加密、校验四个步骤。当然,如果某一算法最终选择了none
,可以跳过这一步骤。
- 压缩比较简单,调用选择的算法直接压缩原始数据包即可。
- 因为SSH支持的只有分组加密算法,所以必须对数据进行填充,以满足分组要求。SSH规定,最小数据数据分组为8个字节,至少要填充4个字节,最多填充255字节。填充后的数据格式是:压缩后数据长度(uint32)+填充长度(byte)+压缩后的数据+填充。
- 数据填充后,是8或者block size的整数倍,这样正好使用加密算法进行加密。无论选择哪种密码模式(CBC、CTR等),密钥更新周期内传递密钥分块参数。
- 校验数据的输入有数据包序号和加密后的数据。校验数据不进行加密直接附在密文后传递。
解密过程与上述过程正好相反。
8. 数据包封装
SSH的每个数据包都是以1个字节数据包类型标识打头的。接下来按照不同的数据包类型序列化或反序列化数据。需要另外考虑的是,一些类型的数据包结构是可变的。
例如,下面分别是固定结构的数据包和可变结构的数据包:
byte SSH_MSG_DISCONNECT
uint32 reason code
string description in ISO-10646 UTF-8 encoding [RFC3629]
string language tag [RFC3066]
下面这个数据包后面的数据就根据request type
的变化而变化。
byte SSH_MSG_CHANNEL_REQUEST
uint32 recipient channel
string request type in US-ASCII characters only
boolean want reply
.... type-specific data follows
三、身份认证
客户端请求需要的服务前,需要向服务端表明身份。首先客户端发送SSH_MSG_USERAUTH_REQUEST
,表明需要请求的服务和打算使用的身份认证方式(publickey
、password
、hostbased
、keyboard-interactive
等)。若服务端接受就直接返回SSH_MSG_USERAUTH_SUCCESS
,这样客户端就不用发送任何身份认证数据证明我是我了。如果服务器觉得还需要进一步验明真身,会返回SSH_MSG_USERAUTH_FAILURE
,并告知服务端支持的身份认证方式。接下来客户端与服务端大战100回合以证明“我就是我!”。
以publickey
为例说明:
- C:发送
SSH_MSG_USERAUTH_REQUEST
,表明使用none
方式验明真身,企图不验证身份。 - S:发送
SSH_MSG_USERAUTH_FAILURE
,告知服务端只支持publickey
方式认证。 - C:发送
SSH_MSG_USERAUTH_REQUEST
,乖乖使用publickey
方式,并附上自己的公钥,不对自己的数据进行签名,企图瞎蒙一个公钥。 - S:发送
SSH_MSG_USERAUTH_PK_OK
,告诉客户端我可以接受你的公钥,但是你要证明你有私钥。 - C:发送
SSH_MSG_USERAUTH_REQUEST
,再次乖乖的把上次传输的数据用自己的私钥进行签名。 - S:心想,这货终于暴露身份了,去数据库里查查这货有没有来注册过。发送
SSH_MSG_USERAUTH_SUCCESS
告诉客户端你这个逗比,给你开通权限了。
上面任何一个过程出那么一小点差错,都会导致身份认证失败。虽然身份认证失败了,但是客户端可知耻而后勇,继续向服务端发起挑战。所以RFC建议客户端尝试一定次数后,要T掉这个逗比客户端。当然啦,如果客户端第一次就用自己的私钥对数据签名了,就会一次通过身份认证。
四、使用连接层服务
连接层服务可复用通道。使用前请求建立通道,用发送窗口控制传输速率,每个通道还可区分数据类型(stdio,stderr等),通道使用后进行关闭。连接层也比较复杂,通道有比较多的类型:session
、x11
、forwarded-tcpip
、direct-tcpip
等。
客户端首先会发送SSH_MSG_CHANNEL_OPEN
数据包,请求开启session
通道,同时也说明客户端的通道号、支持的窗口大小、支持最大数据包大小。服务端会返回SSH_MSG_CHANNEL_OPEN_CONFIRMATION
数据包,确认打开通道,说明服务端的通道号、支持的窗口大小、支持最大数据包大小。这时候客户端和服务端已经知道了对方的通道号、窗口大小、支持的最大数据包大小。
然后客户端发送SSH_MSG_CHANNEL_REQUEST
,确定session
的类型。want reply
字段表示客户端是否希望服务端进行回复,如果设置成true
,服务端必须立即返回SSH_MSG_CHANNEL_SUCCESS
、SSH_MSG_CHANNEL_FAILURE
或别的。exec
会带上一条命令给服务端执行,而shell
不会。现在,可双向传送数据的通道已经建立完毕。客户端和服务端必须在对方窗口空间用完后阻塞数据发送。所以客户端和服务端在收到一定量的数据之后要及时发送SSH_MSG_CHANNEL_WINDOW_ADJUST
调整窗口大小。
任何一方数据发送完成后,可以发送也可不发送SSH_MSG_CHANNEL_EOF
标记,服务端可以选择发送或不发送SSH_MSG_CHANNEL_REQUEST
数据包返回exit-status
。一方发送SSH_MSG_CHANNEL_CLOSE
后就不能继续发送数据,但另一方还可以继续发送。双方都发送SSH_MSG_CHANNEL_CLOSE
后,通道才算完全关闭。这一点类似TCP的半关闭状态。
五、实现Git服务端协议
Git客户端与服务端可以用SSH通道连接,服务端根据客户端请求的命令,启动相应的进程进行交互。SSH只是起到了一个管道的作用。Git客户端在建立SSH连接后,请求session
通道exec
命令。建立管道的代码如下:
var git = new GitService(command, project);
e.Channel.DataReceived += (ss, ee) => git.OnData(ee);
e.Channel.CloseReceived += (ss, ee) => git.OnClose();
git.DataReceived += (ss, ee) => e.Channel.SendData(ee);
git.CloseReceived += (ss, ee) => e.Channel.SendClose(ee);
git.Start();
是不是非常非常的简单?
六、打个广告
为了写本文,专门用C#语言实现了SSH服务端。你可以在github上找到SSH服务端的源码,这个源码顺便实现了Git服务端的例子。我不会告诉你地址是:https://github.com/Aimeast/FxSsh。
既然最后一段提到了实现Git服务端,本来不想告诉你我用C#实现了一个基于ASP.net MVC的Git服务端,它的名字叫做GitCandy。现在已经支持http(s)
和ssh
协议访问了。据我所知,这可曾是全球第一个用C#实现的同时支持http(s)和ssh协议的Git服务端。我也不想告诉你,等到ASP.net vNext发布后,GitCandy会同时支持Windows、Linux、Mac等操作系统。既然已经说了这么多不想说的话,那我就再多说一句吧,GitCandy的源码在https://github.com/Aimeast/GitCandy,使用MIT授权协议。欢迎各位赏脸!
GitCandy交流QQ群:200319579。