【C#】教你纯手工用C#实现SSH协议作为GIT服务端

SSH(Secure Shell)是一种工作在应用层和传输层上的安全协议,能在非安全通道上建立安全通道。提供身份认证、密钥更新、数据校验、通道复用等功能,同时具有良好的可扩展性。本文从SSH的架构开始,教你纯手工打造SSH服务端,顺便再教你如何利用SSH服务端实现Git服务端协议。

目录

  1. SSH架构
  2. 建立传输层
    1. 交换版本信息
    2. 报文结构
    3. 算法
    4. 算法选择
    5. 密钥交换
    6. 密钥更新
    7. 使用算法
    8. 数据包封装
  3. 身份认证
  4. 使用连接层服务
  5. 实现Git服务端协议
  6. 打个广告

一、SSH架构

SSH 1.x协议已经过时,当前版本为2.0。主要由如下RFC文档描述:

另外还有若干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主要由下列类型的算法作为基础:

必须支持的算法原则上需要实现。当然了,如果你肯定的知道对方支持哪些算法,可以偷懒不实现某些必须支持的算法。算法的具体实现请参考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_Sfhash(H)。客户端验证回复参数后响应SSH_MSG_NEWKEYS,之后服务端也响应SSH_MSG_NEWKEYS,之后客户端与服务端使用新的密钥进行加密和校验数据。

按照Diffie-Hellman算法,客户端和服务端分别使用参数ef计算出Shared Secret,然后计算出Exchange Hash,再进一步计算出客户端和服务端加密密钥、初始向量、消息签名密钥。第一次计算出的Exchange Hash作为当次会话的Session Id,作为会话的永久识别标识。

其中K_S是服务端公钥,rsa和dss的序列化格式稍有差异。第一个字段是算法当前算法名称,接下来若干个mpint表示当前算法的公钥参数。

H是当前能获取到的所有参数(包括噪音)的集合,包括了客户端和服务端版本标识、客户端和服务端SSH_MSG_KEXINIT消息的载荷、服务端公钥、efShared 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,可以跳过这一步骤。

  1. 压缩比较简单,调用选择的算法直接压缩原始数据包即可。
  2. 因为SSH支持的只有分组加密算法,所以必须对数据进行填充,以满足分组要求。SSH规定,最小数据数据分组为8个字节,至少要填充4个字节,最多填充255字节。填充后的数据格式是:压缩后数据长度(uint32)+填充长度(byte)+压缩后的数据+填充。
  3. 数据填充后,是8或者block size的整数倍,这样正好使用加密算法进行加密。无论选择哪种密码模式(CBC、CTR等),密钥更新周期内传递密钥分块参数。
  4. 校验数据的输入有数据包序号和加密后的数据。校验数据不进行加密直接附在密文后传递。

解密过程与上述过程正好相反。

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,表明需要请求的服务和打算使用的身份认证方式(publickeypasswordhostbasedkeyboard-interactive等)。若服务端接受就直接返回SSH_MSG_USERAUTH_SUCCESS,这样客户端就不用发送任何身份认证数据证明我是我了。如果服务器觉得还需要进一步验明真身,会返回SSH_MSG_USERAUTH_FAILURE,并告知服务端支持的身份认证方式。接下来客户端与服务端大战100回合以证明“我就是我!”。

publickey为例说明:

  1. C:发送SSH_MSG_USERAUTH_REQUEST,表明使用none方式验明真身,企图不验证身份。
  2. S:发送SSH_MSG_USERAUTH_FAILURE,告知服务端只支持publickey方式认证。
  3. C:发送SSH_MSG_USERAUTH_REQUEST,乖乖使用publickey方式,并附上自己的公钥,不对自己的数据进行签名,企图瞎蒙一个公钥。
  4. S:发送SSH_MSG_USERAUTH_PK_OK,告诉客户端我可以接受你的公钥,但是你要证明你有私钥。
  5. C:发送SSH_MSG_USERAUTH_REQUEST,再次乖乖的把上次传输的数据用自己的私钥进行签名。
  6. S:心想,这货终于暴露身份了,去数据库里查查这货有没有来注册过。发送SSH_MSG_USERAUTH_SUCCESS告诉客户端你这个逗比,给你开通权限了。

上面任何一个过程出那么一小点差错,都会导致身份认证失败。虽然身份认证失败了,但是客户端可知耻而后勇,继续向服务端发起挑战。所以RFC建议客户端尝试一定次数后,要T掉这个逗比客户端。当然啦,如果客户端第一次就用自己的私钥对数据签名了,就会一次通过身份认证。

四、使用连接层服务

连接层服务可复用通道。使用前请求建立通道,用发送窗口控制传输速率,每个通道还可区分数据类型(stdio,stderr等),通道使用后进行关闭。连接层也比较复杂,通道有比较多的类型:sessionx11forwarded-tcpipdirect-tcpip等。

客户端首先会发送SSH_MSG_CHANNEL_OPEN数据包,请求开启session通道,同时也说明客户端的通道号、支持的窗口大小、支持最大数据包大小。服务端会返回SSH_MSG_CHANNEL_OPEN_CONFIRMATION数据包,确认打开通道,说明服务端的通道号、支持的窗口大小、支持最大数据包大小。这时候客户端和服务端已经知道了对方的通道号、窗口大小、支持的最大数据包大小。

然后客户端发送SSH_MSG_CHANNEL_REQUEST,确定session的类型。want reply字段表示客户端是否希望服务端进行回复,如果设置成true,服务端必须立即返回SSH_MSG_CHANNEL_SUCCESSSSH_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。

posted @ 2015-06-17 22:37  Aimeast  阅读(8287)  评论(12编辑  收藏  举报