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。

【C#】教你纯手工用C#实现SSH协议作为GIT服务端的更多相关文章

  1. 3.菜鸟教你一步一步开发 web service 之 axis 服务端创建

    转自:https://blog.csdn.net/shfqbluestone/article/details/37610601 第一步,新建一个工程,如图: 选 Java 写一个工程名,选择好工程路径 ...

  2. HTML5+CSS3+Jquery实现纯手工的垂直时光轴【附源码】

    前言 由于工作中需要,系统中需要记录不同时间发生的事件,为了提升用户体验,决定用时光轴来实现.[据说这个东西挺火的,QQ空间和FB都在用...] 这个时光轴是在 三生石上 这位博主的时光轴基础上修改的 ...

  3. 纯手工打造漂亮的瀑布流,五大插件一个都不少Bootstrap+jQuery+Masonry+imagesLoaded+Lightbox!

    前两天写的文章<纯手工打造漂亮的垂直时间轴,使用最简单的HTML+CSS+JQUERY完成100个版本更新记录的华丽转身!>受到很多网友的喜爱,今天特别推出姊妹篇<纯手工打造漂亮的瀑 ...

  4. 纯手工打造漂亮的垂直时间轴,使用最简单的HTML+CSS+JQUERY完成100个版本更新记录的华丽转身!

    前言 FineUI控件库发展至今已经有 5 个年头,目前论坛注册的QQ会员 5000 多人,捐赠用户 500 多人(捐赠用户转化率达到10%以上,在国内开源领域相信这是一个梦幻数字!也足以证明Fine ...

  5. JAVA+PHP+阿里云组件纯手工实现POP、SMTP、IMAP开发邮件服务器(二)

    java开发邮件服务器的接收模块 用java建立socket服务端,监听端口25,实现SMTP协议.即可完成邮件服务器的接收模块. 这里要注意的是,SMTP协议其实可以分为两种.一种是你用手机.PC等 ...

  6. [置顶] 纯手工打造漂亮的瀑布流,五大插件一个都不少Bootstrap+jQuery+Masonry+imagesLoaded+Lightbox!

    前两天写的文章<纯手工打造漂亮的垂直时间轴,使用最简单的HTML+CSS+JQUERY完成100个版本更新记录的华丽转身!>受到很多网友的喜爱,今天特别推出姊妹篇<纯手工打造漂亮的瀑 ...

  7. [置顶] 纯手工打造漂亮的垂直时间轴,使用最简单的HTML+CSS+JQUERY完成100个版本更新记录的华丽转身!

    前言 FineUI控件库发展至今已经有 5 个年头,目前论坛注册的QQ会员 5000 多人,捐赠用户 500 多人(捐赠用户转化率达到10%以上,在国内开源领域相信这是一个梦幻数字!也足以证明Fine ...

  8. 【转】纯手工玩转 Nginx 日志

    Nginx 日志对于大部分人来说是个未被发掘的宝藏,总结之前做某日志分析系统的经验,和大家分享一下 Nginx 日志的纯手工分析方式. Nginx 日志相关配置有 2 个地方:access_log 和 ...

  9. 纯手工编写的PE可执行程序

    [文章标题]: 纯手工编写的PE可执行程序[文章作者]: Kinney[下载地址]: 自己搜索下载[使用工具]: C32[操作平台]: win 7[作者声明]: 只是感兴趣,没有其他目的.失误之处敬请 ...

随机推荐

  1. HDU 4091 Zombie’s Treasure Chest 分析 难度:1

    Zombie’s Treasure Chest Time Limit: 2000/1000 MS (Java/Others)    Memory Limit: 32768/32768 K (Java/ ...

  2. 推荐十款java开源中文分词组件

    1:Elasticsearch的开源中文分词器 IK Analysis(Star:2471) IK中文分词器在Elasticsearch上的使用.原生IK中文分词是从文件系统中读取词典,es-ik本身 ...

  3. python 加密 解密 签名 验证签名 公钥 私钥 非对称加密 RSA

    加密与解密,这很重要,我们已经可以用pypcap+npcap简单嗅探到网络中的数据包了.而这个工具其实可以嗅探到更多的数据.如果我们和别人之间传输的数据被别人嗅探到,那么信息泄漏,信息被篡改,将给我们 ...

  4. selenium(一)简介,安装,配置,测试。

    简介: Selenium也是一个用于Web应用程序测试的工具.Selenium测试直接运行在浏览器中,就像真正的用户在操作一样.支持的浏览器包括IE.Mozilla Firefox.Mozilla S ...

  5. stringBuild置空方法

    参看连接:http://blog.csdn.net/roserose0002/article/details/6972391

  6. C#实现生产消费者模式

    void test() { int count = 0; // 临界资源区 var queue = new BlockingCollection<string>(); // 生产者线程 T ...

  7. 关于plantera

    在Plantera,您可以建立属于您自己的花园,并且看着新的植物,灌木,树木和动物一起生长. 当您进行游戏,扩张您的花园时,您会吸引圆滚滚的蓝色生物小助手们,它们将帮助您捡果子,收获您的植物 有时候会 ...

  8. Tomcat学习之二:tomcat安装、配置及目录文件说明

    我们看到tomcat目录/bin文件夹里有个tomcat6w.exe,顾名思义就是tomcat以window方式显示控制台.第1次点击打开它时候,可能会提示:tomcat指定的服务未安装,此时我们可以 ...

  9. np.stack() 与 tf.stack() 的简单理解

    说明:np ----> numpy       tf ----> tensorflownp.stack(arrays, axis=0) np.stack(arrays, axis=0) - ...

  10. JPush删除别名及回调函数(SWIFT)

    JPush(极光)删除别名传空字符串即可,官方回调函数的例子为OC的.用SWIFT其实也差不多. //用户登出后删除别名 APService.setAlias("", callba ...