zoukankan      html  css  js  c++  java
  • 【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。

  • 相关阅读:
    tile38 复制配置
    The Guardian’s Migration from MongoDB to PostgreSQL on Amazon RDS
    tile38 一款开源的geo 数据库
    sqler sql 转rest api 的docker 镜像构建(续)使用源码编译
    sqler sql 转rest api javascript 试用
    sqler sql 转rest api redis 接口使用
    sqler sql 转rest api 的docker image
    sqler sql 转rest api 的工具试用
    apache geode 试用
    benthos v1 的一些新功能
  • 原文地址:https://www.cnblogs.com/Aimeast/p/4584540.html
Copyright © 2011-2022 走看看