【C#】教你纯手工用C#实现SSH协议作为GIT服务端
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。
【C#】教你纯手工用C#实现SSH协议作为GIT服务端的更多相关文章
- 3.菜鸟教你一步一步开发 web service 之 axis 服务端创建
转自:https://blog.csdn.net/shfqbluestone/article/details/37610601 第一步,新建一个工程,如图: 选 Java 写一个工程名,选择好工程路径 ...
- HTML5+CSS3+Jquery实现纯手工的垂直时光轴【附源码】
前言 由于工作中需要,系统中需要记录不同时间发生的事件,为了提升用户体验,决定用时光轴来实现.[据说这个东西挺火的,QQ空间和FB都在用...] 这个时光轴是在 三生石上 这位博主的时光轴基础上修改的 ...
- 纯手工打造漂亮的瀑布流,五大插件一个都不少Bootstrap+jQuery+Masonry+imagesLoaded+Lightbox!
前两天写的文章<纯手工打造漂亮的垂直时间轴,使用最简单的HTML+CSS+JQUERY完成100个版本更新记录的华丽转身!>受到很多网友的喜爱,今天特别推出姊妹篇<纯手工打造漂亮的瀑 ...
- 纯手工打造漂亮的垂直时间轴,使用最简单的HTML+CSS+JQUERY完成100个版本更新记录的华丽转身!
前言 FineUI控件库发展至今已经有 5 个年头,目前论坛注册的QQ会员 5000 多人,捐赠用户 500 多人(捐赠用户转化率达到10%以上,在国内开源领域相信这是一个梦幻数字!也足以证明Fine ...
- JAVA+PHP+阿里云组件纯手工实现POP、SMTP、IMAP开发邮件服务器(二)
java开发邮件服务器的接收模块 用java建立socket服务端,监听端口25,实现SMTP协议.即可完成邮件服务器的接收模块. 这里要注意的是,SMTP协议其实可以分为两种.一种是你用手机.PC等 ...
- [置顶] 纯手工打造漂亮的瀑布流,五大插件一个都不少Bootstrap+jQuery+Masonry+imagesLoaded+Lightbox!
前两天写的文章<纯手工打造漂亮的垂直时间轴,使用最简单的HTML+CSS+JQUERY完成100个版本更新记录的华丽转身!>受到很多网友的喜爱,今天特别推出姊妹篇<纯手工打造漂亮的瀑 ...
- [置顶] 纯手工打造漂亮的垂直时间轴,使用最简单的HTML+CSS+JQUERY完成100个版本更新记录的华丽转身!
前言 FineUI控件库发展至今已经有 5 个年头,目前论坛注册的QQ会员 5000 多人,捐赠用户 500 多人(捐赠用户转化率达到10%以上,在国内开源领域相信这是一个梦幻数字!也足以证明Fine ...
- 【转】纯手工玩转 Nginx 日志
Nginx 日志对于大部分人来说是个未被发掘的宝藏,总结之前做某日志分析系统的经验,和大家分享一下 Nginx 日志的纯手工分析方式. Nginx 日志相关配置有 2 个地方:access_log 和 ...
- 纯手工编写的PE可执行程序
[文章标题]: 纯手工编写的PE可执行程序[文章作者]: Kinney[下载地址]: 自己搜索下载[使用工具]: C32[操作平台]: win 7[作者声明]: 只是感兴趣,没有其他目的.失误之处敬请 ...
随机推荐
- HDU 4091 Zombie’s Treasure Chest 分析 难度:1
Zombie’s Treasure Chest Time Limit: 2000/1000 MS (Java/Others) Memory Limit: 32768/32768 K (Java/ ...
- 推荐十款java开源中文分词组件
1:Elasticsearch的开源中文分词器 IK Analysis(Star:2471) IK中文分词器在Elasticsearch上的使用.原生IK中文分词是从文件系统中读取词典,es-ik本身 ...
- python 加密 解密 签名 验证签名 公钥 私钥 非对称加密 RSA
加密与解密,这很重要,我们已经可以用pypcap+npcap简单嗅探到网络中的数据包了.而这个工具其实可以嗅探到更多的数据.如果我们和别人之间传输的数据被别人嗅探到,那么信息泄漏,信息被篡改,将给我们 ...
- selenium(一)简介,安装,配置,测试。
简介: Selenium也是一个用于Web应用程序测试的工具.Selenium测试直接运行在浏览器中,就像真正的用户在操作一样.支持的浏览器包括IE.Mozilla Firefox.Mozilla S ...
- stringBuild置空方法
参看连接:http://blog.csdn.net/roserose0002/article/details/6972391
- C#实现生产消费者模式
void test() { int count = 0; // 临界资源区 var queue = new BlockingCollection<string>(); // 生产者线程 T ...
- 关于plantera
在Plantera,您可以建立属于您自己的花园,并且看着新的植物,灌木,树木和动物一起生长. 当您进行游戏,扩张您的花园时,您会吸引圆滚滚的蓝色生物小助手们,它们将帮助您捡果子,收获您的植物 有时候会 ...
- Tomcat学习之二:tomcat安装、配置及目录文件说明
我们看到tomcat目录/bin文件夹里有个tomcat6w.exe,顾名思义就是tomcat以window方式显示控制台.第1次点击打开它时候,可能会提示:tomcat指定的服务未安装,此时我们可以 ...
- np.stack() 与 tf.stack() 的简单理解
说明:np ----> numpy tf ----> tensorflownp.stack(arrays, axis=0) np.stack(arrays, axis=0) - ...
- JPush删除别名及回调函数(SWIFT)
JPush(极光)删除别名传空字符串即可,官方回调函数的例子为OC的.用SWIFT其实也差不多. //用户登出后删除别名 APService.setAlias("", callba ...