浅析 SSH 协议(一) —— 协商阶段

前段时间在调研客户环境 SSH 算法协商失败的问题时,顺便浅读了一下相关的 RFC 文档。为加深理解又尝试根据协议内容实现了一个简陋版的 SSH 客户端,写几篇小文方便后日查阅。

ssh_client

准备工作

一些辅助工具能让协议理解和代码调试事半功倍。

  • WireShark:过滤 tcp.port==22 可以自动解析 SSH 报文,如果是非 22 端口需要右键 Decode 指定将特定端口的报文解析成 SSH 报文。

ssh_client

  • 一个可以调试的 SSH 服务端。个人是基于 Apache Mina SSHD 搭建了一个简易服务端。

连接建立阶段

客户端向服务端发起 TCP 连接请求。我的简易程序入口 Main 函数基本结构如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
class SimpleSSHClient(
val host: String,
val port: Int,
val username: String,
val password: String
) {
private val logger = LoggerFactory.getLogger(getClass().getName())

private var socket: Socket = _
private var in: InputStream = _
private var out: OutputStream = _

def write(bytes: Array[Byte]): Unit = {
out.write(bytes)
out.flush()
}

def start(): Unit = {
try {
// connect to the server
createConnection()

// send/receive packets to/from the server side
// ...

// close connection...
// ...
} finally {
if (in != null) in.close()
if (out != null) out.close()
if (socket != null) socket.close()
}
}

private def createConnection(): Unit = {
socket = new Socket(host, port)
in = socket.getInputStream()
out = socket.getOutputStream()
logger.info("Connected to the server {}:{}", host, port)
}
}

object SimpleSSHClient extends App {
if (args.length < 4) {
println("Usage: SimpleSSHClient <host> <port> <username> <password>")
System.exit(1)
}
val client = new SimpleSSHClient(args(0), args(1).toInt, args(2), args(3))
client.start()
}

程序的 Main 函数接收 host, port, usernamepassword 四个参数,随后会实例化 SimpleSSHClient 类并调用 start 方法,start 方法负责整个连接过程,在函数开始创建 Socket 连接获得 InputStream inOutStream out 两个对象用于整个连接过程中的数据读写。

版本协商阶段

客户端和服务端的版本协商协议定义于 RFC-4253 Section 4.2 - Protocol Version Exchange

客户端与服务端建立 TCP 连接之后,双方会向对方发送自己的版本号信息(顺序无所谓),格式如下:

SSH-protoversion-softwareversion SP comments CR LF

protoversion 固定为 2.0,Comments 为可选且只有该字段存在的时候才需要前面的 SP 即空格,softwareversion 为软件自定义一般同时包括软件名和软件版本号,CRLF 分别为回车符和换行符(\r\n,对应十六进制 0x0d0x0a)。数据均为明文。

以我的实现为例,向服务端发送的版本号如下:

SSH-2.0-SimpleSSH_0.0.1

对应的十六进制报文:

收到的服务端版本信息如下:

程序实现这边,为了方便往字节数组里面塞各种类型的数据,我们定义了一个辅助类 SSHBuffer,用于生成一个数组缓存并提供方法往缓存写入不同类型的数据,以及转换为字节数组,其实现如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
class SSHBuffer(initData: Array[Byte] = Array.empty[Byte]) {
private val buffer = collection.mutable.ArrayBuffer.empty[Byte]
def length: Int = buffer.length

def putByte(num: Byte): Unit = {
buffer += num
}

def putByteArray(bytes: Array[Byte]): Unit = {
buffer.appendAll(bytes)
}

def putInt(num: Int): Unit = {
putByteArray(
Array(
((num >> 24) & 0xff).toByte,
((num >> 16) & 0xff).toByte,
((num >> 8) & 0xff).toByte,
(num & 0xff).toByte
))
}

def putString(str: String): Unit = {
val currentLen = length
putInt(str.length())
putByteArray(str.getBytes())
hexOrHighligthRange += s"""$currentLen:$length:$color:(String\\, ${length - currentLen - 3} bytes)"""
}

// Methods to handle other types...
}

每个同类型的报文在程序里面都会定义一个 Packet 类或者单例,在里面实现报文的发送服务端返回报文的接收,比如版本协商对应着 HelloPacket 类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
package me.ihainan.packets

import org.slf4j.LoggerFactory
import me.ihainan.utils.SSHBuffer
import me.ihainan.SSHSession
import java.io.InputStream

object HelloPacket {
private val logger = LoggerFactory.getLogger(getClass().getName())

private val SSH_CLIENT_VERSON = "SSH-2.0-SimpleSSH_0.0.1"

def generateVersionPacket(): SSHBuffer = {
val buffer = new SSHBuffer()
logger.info(s" clientSSHVersion = $SSH_CLIENT_VERSON")
buffer.putByteArray(SSH_CLIENT_VERSON.getBytes())
buffer.putByte(0x0d.toByte) // CR
buffer.putByte(0x0a.toByte) // LF
SSHSession.setClientVersion(SSH_CLIENT_VERSON)
buffer
}

def receiveServerVersionPacket(in: InputStream): Unit = {
val buffer = collection.mutable.ArrayBuffer.empty[Byte]
var lastByte: Int = -1
var currentByte: Int = -1
var serverVersion: String = null
while (serverVersion == null && {
currentByte = in.read; currentByte != -1
}) {
if (lastByte == 0x0d && currentByte == 0x0a) {
buffer.trimEnd(1)
serverVersion = new String(buffer.toArray)
logger.info(s" serverSSHVersion = $serverVersion")
}
buffer += currentByte.toByte
lastByte = currentByte
}
SSHSession.setServerVersion(serverVersion)
}
}

generateVersionPacket 函数将 SSH_CLIENT_VERSON 字符串对应的字节数组以及 0x0d0x0a 两个字节写入到 Buffer 中并返回,receiveServerVersionPacket 则是持续接收数据直到接收到了连续的 0x0d0x0a 才停止。

SSHSession 是一个特殊的全局单例对应,用于存储在后续通讯过程中还会被用到的一些信息,比如版本交换过程的版本信息,后续会被用于验证服务端的身份等。

SimpleSSHClient.start 函数中调用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class SimpleSSHClient(...) {
def start(): Unit = {
// ...
// Send client's SSH version to the server
sendClientVersion()

// Receive server's SSH version
receiveServerVersion()
// ...
}

private def sendClientVersion(): Unit = {
write(HelloPacket.generateVersionPacket().getData)
}

private def receiveServerVersion(): Unit = {
HelloPacket.receiveServerVersionPacket(in)
}
}

版本交换之后,双方会根据收到的信息决定是否继续连接,以及如果 SSH 版本不同,双方还需要进一步做兼容性考虑,具体可以参考 RFC-4253 Section 5 - Compatibility With Old SSH Versions

接下来双方会进入算法协商阶段。

算法协商阶段 —— 涉及算法

SSH 在整个通讯过程中会用到多种算法,包括密钥交换算法、主机密钥算法、数据加密算法、消息认证码算法和压缩算法,双方出于兼容性考虑(比如使用的 OpenSSL 版本不同导致支持算法的范围会有差距,或者某些算法出于安全性考虑已经被淘汰),需要协商最终使用的算法。

在具体讲解算法协商的过程之前,我们先来简单聊聊这些算法的用途。

数据加密算法

SSH 在传输真正的用户数据(比如用户名密码,客户端的命令和服务端的执行结果)的时候,出于效率考虑,使用的是对称加密算法。双方拥有相同的一份密钥,客户端用这个密钥加密的数据,服务端用相同密钥解开,反之亦然。

symnmetric_encryption

我们把这个密钥叫做共享密钥(Shared Secret),这个密钥的生成与加解密对应的算法就是算法协商阶段需要协商的数据加密算法

主流的算法有:aes256-cbc, aes256-ctr, aes256-gcmChaCha20-Poly1305 等等。我们不会讨论算法实现的细节,暂时只需要了解它的用途即可。

但是双方在连接之前显然不可能已经有这个共享密钥了,更不可能直接明文传输,所以我们需要想足够安全的办法来生成一个共享密钥。

密钥交换算法与主机密钥算法

在各自只拥有部分信息的时候,协商出来一个只有双方知道,别人不可能知道的共享密钥。我们把这个过程使用的算法叫做密钥交换算法

密钥交换算法的神奇之处在于,即便协商过程传输的数据是明文(可被监听截获),但是最后协商出来的共享密钥相同且只有连接双方可知。我们以相对简单的 diffie-hellman-group14-sha1 密钥交换算法(也是我的应用程序所使用的算法)为例,简单说说大概的交换流程。

diffie-hellman-group14-sha1 这个名字实际分为三部分:实际的交换算法 diffie-hellman(Diffie–Hellman 密钥交换算法),交换算法使用的参数组 group14 以及哈希算法 sha1

在 Diffie–Hellman 密钥交换算法中,客户端和服务端各自生成一个非对称公私钥对,与对称加密不一样,非对称加密有一对不同的密钥,分别为私钥和公钥,公钥加密只能私钥解,私钥加密也只能公钥解。

asymmetric_encryption

双方明文方式交换公钥,客户端根据客户端私钥和服务端公钥生成一个密钥,服务端根据服务端私钥和客户端公钥生成一个密钥,两个密钥是相同的,可以用作共享密钥。同时因为自己的私钥没有暴露,以及 DH 选择的公钥生成函数本身难以被破解,即使明文公钥被截获,其他人也无法生成这个共享密钥或者反推出私钥。具体的数学原理可参考这篇文章

在生成公钥的过程中双方需要选择相同的两个参数:大质数 $p$ 和它的一个原根 $g$,算法名中的 group14 则是一组约定好的公开参数(编号为 14,素数的位数为 2048 位),在 RFC-3526 Section 3 - 2048-bit MODP Group 中定义。

到此,我们还有一个问题需要考虑,虽然无需担心共享密钥泄露,但是客户端如何验证密钥协商的过程中返回来的公钥一定就是服务端的。换句话说是要如何防范中间人攻击

SSH 的解决办法是,要求服务端自身也要有一套独立于上述 DH 密钥的非对称公私钥,这对密钥所使用的算法即主机密钥算法,比如 rsa-sha2-512。需要注意这对密钥不是用户自己 ~/.ssh/ 下的密钥对(通常使用命令 ssh-keygen 生成),而是 SSH Server 服务在安装完成之后生成的密钥对,一般是在 /etc/ssh 目录下面:

1
2
3
4
# ihainan @ ubuntu-server in ~ [13:41:21]
$ ls -1 /etc/ssh/ssh_host_rsa_*
/etc/ssh/ssh_host_rsa_key
/etc/ssh/ssh_host_rsa_key.pub

在密钥协商阶段,服务端除了附上 DH 协商期间自己生成的 DH 服务端公钥,还要附上自己本机的公钥,以及一段签名。签名的过程如下(以 diffie-hellman-group14-sha1 + rsa-sha2-512 为例,参见 RFC-4253 Section 8 - Diffie-Hellman Key Exchange):

服务端收集与客户端通信过程中接收或者发送的数据,包括:

  • 客户端在版本协商阶段发送过来的版本字符串(V_C)。
  • 服务端在版本协商阶段发送出去的版本字符串(V_S
  • 客户端在算法协商阶段发送过来的有效数据部分(I_C)。
  • 服务端在算法协商阶段发送出去的有效数据部分(I_S)。
  • 服务端在密钥协商阶段即将给客户端发送出去的 DH 服务端公钥(K_S)。
  • 客户端在密钥协商阶段发送过来的的 DH 客户端公钥(e),以及计算出来的共享密钥(K)。

随后服务端把这些数据按上述顺序放到一个字节数组里面,这里需要注意不同的数据内容要按照各自的格式来放置,比如版本字符串和 DH 公钥(是一个大整数,在 SSH 实现中用多精度整数 mpint 表示,可以理解成 Java 中的 java.lang.BigInteger 的内部字节数组表示)需要加上长度信息,后者还需要做防负数处理。

再接着使用 diffie-hellman-group14-sha1 末尾定义的哈希算法 sha-1 生成数据的摘要:

1
H = hash(V_C || V_S || I_C || I_S || K_S || e || f || K)

最后使用自己的 RSA 私钥对数据进行加密生成签名,在算法协商阶段,把自己的 RSA 公钥和签名一起发给客户端。

现在轮到客户端验证服务端身份,客户端一样可以收集到服务端生成摘要所使用的所有数据,可以使用约定的 sha-1 算法生成相同的摘要,再提取出服务端的 RSA 公钥和签名,使用公钥解密服务端 RSA 私钥加密过的签名,再同自己生成的签名进行对比,如果相同而说明连接到了正确的服务器。

这一步有个关键的地方:我们验证签名的一个前提是信任报文里面的服务端 RSA 公钥,理论上我们是需要去手工验证这个公钥是不是自己想连的服务器(一些知名服务器会在网站上公布自己的公钥相关信息,比如 GitHub),这也是为什么我们第一次连接一个 SSH Server 的时候,需要去确认公钥指纹(即哈希值):

1
2
3
4
5
6
$ ssh ihainan@xx.ihainan.me
The authenticity of host 'xx.ihainan.me (198.18.xx.xx)' can't be established.
ED25519 key fingerprint is SHA256:A7Rj4dtpjzXXAf0P34EAmEFPGxFmLOUWgOPA5IkJGM4.
This host key is known by the following other names/addresses:
~/.ssh/known_hosts:91: 80.66.xxx.xx
Are you sure you want to continue connecting (yes/no/[fingerprint])?

消息认证码算法

算法协商结束之后,双端可以使用共享密钥加解密数据了,但为了防止传输过程中数据被篡改,我们还需要在每个加密报文之后附上基于已加密数据以及双方的共享密钥生成的一段固定长度的哈希字符串,称为消息验证码(Message Authentication Code, MAC)。生成消息验证码的算法即为消息认证码算法,比如我在程序里面使用的 hmac-sha1

接收端接收到数据之后,用协商好的相同算法、加密数据和共享密钥,生成消息验证码,与报文附带的进行对比,从而确保数据没有篡改过。

数据压缩算法

在算法协商完成之后,传输数据时可以对有效数据部分进行压缩,所选算法即为数据压缩算法,比如 zlibzlib@openssh.com,出于简单考虑,我的应用程序选择不压缩:none

算法协商阶段 —— 报文格式

聊完理论内容,让我们来看看实际的报文格式。从这个阶段开始,双方发送的报文都会类似于如下格式:

1
2
| Packet Length | Padding Length | Payload | Padding | MAC |
| 报文长度(4 字节)| 末尾填充字段的字节数(1 字节)| 有效负荷 | 填充 | MAC |

本节暂时不考虑 MAC(在共享密钥出来之前都不会有 MAC 字段)。我们在具体解释之前先找个例子来演示一下报文格式。

假设客户端要发给服务端的报文,有效数据部分长度是 20 个字节,通过某种计算得出需要在末尾补上 7 个字节的填充数据,所以 Padding Length 的值就是 1 个字节的 0x07,而 Packet Length 的值为 Payload 实际长度 + Padding 的长度 + 1 = 20 + 7 + 1 = 28(0x1c),最后总报文的长度需要加上 Packet Length 整数自身占用的 4 bytes,总长度为 32 字节。如下所示,使用不同颜色标记四个数据块:

之所以需要填充块,一是后面一旦使用加密之后,加密算法(比如 AES)要求输入数据必须是规定块的大小(比如 AES-256 规定是 16 字节)的倍数;二是为了防止监听者根据实际负载大小对报文内容做推断。实际使用的时候填充块应该是随机数据,但出于调试方便我都填充了 0x00

RFC-4253 Section 6 - Binary Packet Protocol 规定最后总长度必须是加密块长度(比如上面我们说的 AES-256 的 16 字节)或者 8 的倍数(取决于谁更大,在未加密之前则直接取 8),且最小是 4 个字节。因为我们可以写出下面的填充长度计算函数 calculatePaddingLength,放到 SSHBuffer 类中,并实现 wrapWithPadding 方法为实际负载数据添加前后所需的数据,构建出最后的报文数据。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
class SSHBuffer(initData: Array[Byte] = Array.empty[Byte]) {
// ...
private def calculatePaddingLength(payloadLength: Int, blockSize: Int = 8): Int = {
val initialLength = 4 + 1 + payloadLength
val paddingLength = blockSize - (initialLength % blockSize)

// padding length must be at least 4 bytes
if (paddingLength < 4) {
paddingLength + blockSize
} else {
paddingLength
}
}

def wrapWithPadding(blockSize: Int = 8): SSHBuffer = {
val bytes = getData
val newBuffer = new SSHBuffer()
val paddingLength = calculatePaddingLength(bytes.length, blockSize)
val packetLength = bytes.length + paddingLength + 1
newBuffer.putInt(packetLength)
newBuffer.putByte(paddingLength.toByte)
newBuffer.putByteArray(bytes)
(0 until paddingLength).foreach(_ => newBuffer.putByte(0.toByte))
newBuffer
}
}

同时为了方便解析从服务端发送给客户端的数据,我们定义了抽象类 StreamBufferReader,内部 readPacketLength 方法从 InputStream 读取 Packet Length 的四字节整数,readPacketData 则是根据 Packet Length 继续读取剩下的数据。

StreamBufferReader 有两个实现,其中 SSHStreamBufferReader 类读取未加密的数据,与 SSHBuffer 类似,它定义了从 InputStream 中读取各种类型的数据的函数,比如读取一个字符串,会优先读取长度信息,再读取该长度的字节数据并构建成一个字符串。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
abstract class StreamBufferReader(in: InputStream) {
private val logger = LoggerFactory.getLogger(getClass().getName())

def readPacketLength(in: InputStream): Int = {
val bytes = new Array[Byte](4)
val bytesRead = in.read(bytes)
if (bytesRead != 4) {
throw new IllegalStateException(
"Could not read 4 bytes from the InputStream"
)
}
ByteBuffer.wrap(bytes).getInt
}

def readPacketData(in: InputStream, length: Int): Array[Byte] = {
val bytes = new Array[Byte](length)
val bytesRead = in.read(bytes)
if (bytesRead != length) {
throw new IllegalStateException(
s"Could not read $length bytes from the InputStream"
)
}
bytes
}
}

class SSHStreamBufferReader(in: InputStream) extends StreamBufferReader(in) {
private val _buffer = new SSHBuffer()
private val _packetLength = readInt(in)
private val data = readByteArray(in, packetLength)

_buffer.putByteArray(data)

def reader = new SSHBufferReader(_buffer.getData)

def packetLength = _packetLength

def getByte(): Byte = {
val num = buffer(index)
index += 1
num
}

def getByteArray(): Array[Byte] = {
val len = getInt()
getByteArray(len)
}

// Other reader methods...
}

接下来我们终于可以来看算法协商的报文格式了。客户端和服务端的报文格式完全一致,以我写的程序的客户端算法协商报文为例,WireShark 抓包如下:

client_algorithms

数据负载部分包含如下内容,定义在 RFC-4253 Section 7.1 - Algorithm Negotiation 中:

  • 消息编号(message code):1 字节,固定为 SSH_MSG_KEXINIT,即 0x14
  • Cookie:16 字节,随机数据。
  • 密钥交换算法(kex_algorithms):字符串形式的算法名列表,用逗号隔开,表示本机支持的所有密钥交换算法。SSH 报文中除了版本协商阶段,其他字符串都需要在前面用 4 个字节表示该字符串的长度。这里 diffie-hellman-group14-sha256,ext-info-c,kex-strict-c-v00@openssh.com 的字符串长度为 69,所以前面的四个字节为 0x00000045。后续的算法列表也是类似的表示方式。
  • 主机密钥算法列表(server_host_key_algorithms)
  • 客户端到服务端数据的加密算法(encryption_algorithms_client_to_server):SSH 允许两边使用不同的对称加密算法,但一般情况下两者是一样的。
  • 服务端到客户端数据的加密算法(encryption_algorithms_server_to_client)
  • 客户端到服务端数据的消息认证码算法(mac_algorithms_client_to_server)
  • 服务端到客户端数据的消息认证码算法(mac_algorithms_server_to_client):通常与上面一样。
  • 客户端到服务端数据的压缩算法(compression_algorithms_client_to_server)
  • 服务端到客户端数据的压缩算法(compression_algorithms_server_to_client)
  • 客户端希望服务端使用的语言列表(languages_client_to_server):方便服务端选择合适的语言用于欢迎消息和错误信息展示等。通常是空字符串,长度需要设置为 0。
  • 服务端希望客户端使用的语言列表(languages_client_to_server)
  • 随后发送密钥交换报文标志位(first_kex_packet_follows):1 字节,布尔型(0 为 False,1 为 True)。如果这个值设置为 True,表明发送端会猜测接收端选择的算法类型,然后在算法协商报文发送之后立刻发送一个密钥交换报文。如果接收端收到报文时候发现与自己协商的不一致,就会丢弃这个报文,并返回报文通知发送端(消息编号为 SSH_MSG_DISCARD)提醒发送端发送正确的密钥交换报文。我们暂时只考虑 first_kex_packet_follows 为 0(False)的情况来减少复杂度。
  • 保留字段(reserved):4 字节,固定为 0x00000000

上面展示的报文的十六进制数据,把鼠标放到对应块上会高亮该区域并显示该报文是属于哪个字段:

双方在收到对方的算法协商报文之后,会从列表中选出第一个自己支持的算法作为最后的选择。

代码实现方面,与版本协商类似,我们写了一个 KeyExchangePacket 类专门用来构建要发给客户端的报文,以及接受和解析服务端发送过来的报文。里面也定义了我们的简易客户端支持的算法类型。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
package me.ihainan.packets

import scala.util.Random
import java.io.{ByteArrayInputStream, InputStream}
import java.nio.ByteBuffer
import org.apache.commons.codec.binary.Hex;
import me.ihainan.utils.SSHBuffer
import me.ihainan.utils.SSHStreamBufferReader
import me.ihainan.utils.SSHBufferReader
import me.ihainan.SSHSession
import org.slf4j.LoggerFactory
import me.ihainan.utils.SSHFormatter

// https://datatracker.ietf.org/doc/html/rfc4253#section-7
class KeyExchangePacket(
val cookie: Array[Byte],
val keyExchangeAlgorithms: String,
val serverHostKeyAlgorithms: String,
val encryptionAlgorithmsClientToServer: String,
val encryptionAlgorithmsServerToClient: String,
val macAlgorithmsClientToServer: String,
val macAlgorithmsServerToClient: String,
val compressionAlgorithmsClientToServer: String,
val compressionAlgorithmsServerToClient: String,
val languagesClientToServer: String,
val languagesServerToClient: String,
val firstKexPacketFollows: Byte
) {
private val logger = LoggerFactory.getLogger(getClass().getName())
import KeyExchangePacket._

private val random = new Random()

def generatePayload(): SSHBuffer = {
val buffer = new SSHBuffer()
buffer.putByte(SSH_MSG_KEXINIT)
buffer.putByteArray(cookie)
for (algorithm <- clientAlgorithms) {
buffer.putString(algorithm)
}
buffer.putByte(firstKexPacketFollows)
buffer.putInt(reserved)
buffer
}

def generatePacket(): SSHBuffer = {
val payloadBuffer = generatePayload()
SSHSession.setIC(payloadBuffer.getData) // IC contains the SSH_MSG_KEXINIT
val buffer = payloadBuffer.wrapWithPadding()
buffer
}
}

object KeyExchangePacket {
private val logger = LoggerFactory.getLogger(getClass().getName())

private val SSH_MSG_KEXINIT = 0x14.toByte
val cookie = Array(0x6f, 0x34, 0x3a, 0xdc, 0x69, 0x15, 0x84, 0x4a, 0x9d,
0x84, 0x2d, 0x36, 0x4c, 0x9c, 0xee, 0xcb).map(_.toByte)
val keyExchangeAlgorithms =
"diffie-hellman-group14-sha256,ext-info-c,kex-strict-c-v00@openssh.com"
val serverHostKeyAlgorithms = "rsa-sha2-512"
val encryptionAlgorithmsClientToServer = "aes256-ctr"
val encryptionAlgorithmsServerToClient = "aes256-ctr"
val macAlgorithmsClientToServer = "hmac-sha1"
val macAlgorithmsServerToClient = "hmac-sha1"
val compressionAlgorithmsClientToServer = "none"
val compressionAlgorithmsServerToClient = "none"
val languagesClientToServer = ""
val languagesServerToClient = ""
val firstKexPacketFollows = 0.toByte
val clientAlgorithms = Array(
keyExchangeAlgorithms,
serverHostKeyAlgorithms,
encryptionAlgorithmsClientToServer,
encryptionAlgorithmsServerToClient,
macAlgorithmsClientToServer,
macAlgorithmsServerToClient,
compressionAlgorithmsClientToServer,
compressionAlgorithmsServerToClient,
languagesClientToServer,
languagesServerToClient
)
val reserved: Int = 0

def getClientAlgorithms(): KeyExchangePacket = {
val clientAlgorithms = new KeyExchangePacket(
cookie = cookie,
keyExchangeAlgorithms = keyExchangeAlgorithms,
serverHostKeyAlgorithms = serverHostKeyAlgorithms,
encryptionAlgorithmsClientToServer = encryptionAlgorithmsClientToServer,
encryptionAlgorithmsServerToClient = encryptionAlgorithmsServerToClient,
macAlgorithmsClientToServer = macAlgorithmsClientToServer,
macAlgorithmsServerToClient = macAlgorithmsServerToClient,
compressionAlgorithmsClientToServer = compressionAlgorithmsClientToServer,
compressionAlgorithmsServerToClient = compressionAlgorithmsServerToClient,
languagesClientToServer = languagesClientToServer,
languagesServerToClient = languagesServerToClient,
firstKexPacketFollows = firstKexPacketFollows
)
clientAlgorithms
}

def readAlgorithmsFromInputStream(in: InputStream): KeyExchangePacket = {
// parse packet
val streamReader = new SSHStreamBufferReader(in)
val reader = streamReader.reader
val packetLength = streamReader.packetLength
val paddingLength = reader.getByte()
val payloadBytes = reader.getByteArray(packetLength - paddingLength - 1)
val payloadReader = new SSHBufferReader(payloadBytes)

// Save into SSHSession
SSHSession.setIS(payloadBytes)

// read payload
val command = payloadReader.getByte()
logger.debug(s" packet command = $command")
val cookie = payloadReader.getByteArray(16)
val keyExchangeAlgorithms = payloadReader.getString()
val serverHostKeyAlgorithms = payloadReader.getString()
val encryptionAlgorithmsClientToServer = payloadReader.getString()
val encryptionAlgorithmsServerToClient = payloadReader.getString()
val macAlgorithmsClientToServer = payloadReader.getString()
val macAlgorithmsServerToClient = payloadReader.getString()
val compressionAlgorithmsClientToServer = payloadReader.getString()
val compressionAlgorithmsServerToClient = payloadReader.getString()
val languagesClientToServer = payloadReader.getString()
val languagesServerToClient = payloadReader.getString()
val firstKexPacketFollows = payloadReader.getByte()

new KeyExchangePacket(
cookie,
keyExchangeAlgorithms,
serverHostKeyAlgorithms,
encryptionAlgorithmsClientToServer,
encryptionAlgorithmsServerToClient,
macAlgorithmsClientToServer,
macAlgorithmsServerToClient,
compressionAlgorithmsClientToServer,
compressionAlgorithmsServerToClient,
languagesClientToServer,
languagesServerToClient,
firstKexPacketFollows
)
}
}

SimpleSSHClient 类的 start 方法中发送和接受算法协商报文:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class SimpleSSHClient(...) {
def start(): Unit = {
// ...
// client key exchange init
sendClientAlgorithms()

// server key exchange init
receiveServerAlgorithms()
// ...
}

private def sendClientAlgorithms(): Unit = {
val buffer = clientAlrithms.generatePacket()
write(buffer.getData)
}

private def receiveServerAlgorithms(): Unit = {
serverAlgorithms = KeyExchangePacket.readAlgorithmsFromInputStream(in)
}
}

密钥交换阶段 —— 计算共享密钥

密钥交换阶段,客户端先给服务端发送自己的 DH 公钥,并接受服务端的返回。我们先看客户端的报文抓包(以 diffie-hellman-group14-sha256 为例):

client_to_server_kex

有效载荷包含如下字段,定义于 RFC-4253 Section 8 - Diffie-Hellman Key Exchange 中:

  • 消息编号(message code):1 字节,固定为 SSH_MSG_KEXDH_INIT,即 0x1e
  • 客户端 DH 公钥(DH client e):多精度整数,前面四位为整型长度值,后面会跟随该长度的字节数组。

一个十六进制数据例子如下:

在代码实现中,我们需要考虑如何使用 DH 算法生成公私钥对,以及如何将公钥以多精度整数的形式表示。我的代码实现了一个 DHKeyExchangeAlgorithm 类用来生成公私钥对,以及等获取到服务端公钥之后,生成共享密钥。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
package me.ihainan.algorithms

import java.security._
import java.util.Base64
import javax.crypto.KeyAgreement
import java.math.BigInteger
import javax.crypto.spec.DHParameterSpec
import javax.crypto.interfaces.DHPublicKey
import javax.crypto.spec.DHPublicKeySpec
import java.security.spec.RSAPublicKeySpec
import java.security.interfaces.RSAPublicKey
import java.nio.charset.StandardCharsets
import java.nio.ByteBuffer
import javax.crypto.interfaces.DHPrivateKey
import scala.util.control.Exception.By

class DHKeyExchangeAlgorithm {
val pBytes: Array[Byte] = Array(0x00.toByte, ...)
private val P = new BigInteger(pBytes)
private val G = BigInteger.valueOf(2)

// generate client's DH key pairs
private val keyPairGenerator = KeyPairGenerator.getInstance("DH")
private val keyAgreement = KeyAgreement.getInstance("DH")
private val dhSpec = new DHParameterSpec(P, G)
keyPairGenerator.initialize(dhSpec)

// client's
private val clientKeyPair = keyPairGenerator.generateKeyPair()
private val clientDHPrivateKey = clientKeyPair.getPrivate
val clientDHPublicKey = clientKeyPair.getPublic.asInstanceOf[DHPublicKey]
val clientE: BigInteger = clientDHPublicKey.getY()

// Other methods...
}

服务端发送给客户端的密钥交换报文会相对复杂很多,抓包如下:

kex_server_to_client

有效载荷包含如下字段,同样定义于 RFC-4253 第八小节 中:

  • 消息编号(message code):1 字节,固定为 SSH_MSG_KEXDH_REPLY,即 0x1f
  • 服务端的主机公钥与证书(server public host key and certificates):服务端主机公钥和证书信息。注意这里不是临时生成的 DH 证书而是服务端自己的主机证书,下面的结构以 RSA 证书为例。
    • 主机证书长度(host key length):4 字节整数,后续字段的总长度,不包含自身的 4 个字节。
    • 主机证书类型(host key type):字符串,如果是 RSA 整数则是 7 个字节的字符串 ssh-rsa
    • 公钥指数 $e$(RSA public exponent):多精度整数,与模式 $N$ 构成 RSA 公钥。
    • 公钥模数 $N$(RSA modulus):多精度整数,与指数 $e$ 构成 RSA 公钥。
  • 服务端 DH 公钥(DH server f):多精度整数。
  • 签名信息(KEX H signature):四个字节的长度信息,和对应长度的字节数组。

一个十六进制数据例子如下:

获取这些数据之后,客户端可以开始构建服务端的 RSA 公钥和 DH 公钥,前者在 RSAAlgorithm 中实现,后者则依旧在 DHKeyExchangeAlgorithm 类中实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
package me.ihainan.algorithms

import java.security.interfaces.RSAPublicKey
import java.math.BigInteger
import java.security.spec.RSAPublicKeySpec
import java.security.KeyFactory

object RSAAlgorithm {
def generateRSAPublicKey(e: BigInteger, n: BigInteger): RSAPublicKey = {
val spec = new RSAPublicKeySpec(n, e)
val keyFactory = KeyFactory.getInstance("RSA")
keyFactory.generatePublic(spec).asInstanceOf[RSAPublicKey]
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
package me.ihainan.algorithms

class DHKeyExchangeAlgorithm {
// ...

// server's
private var serverDHPublicKey: DHPublicKey = _

// shared
private var _shared_secret: Array[Byte] = _

def sharedSecret = _shared_secret

def setServerDHPublicKey(dhServerF: Array[Byte]): Unit = {
val f = new BigInteger(1, dhServerF);
val dhPublicKeySpec = new DHPublicKeySpec(f, P, G);
val keyFactory = KeyFactory.getInstance("DH");
serverDHPublicKey = keyFactory.generatePublic(dhPublicKeySpec).asInstanceOf[DHPublicKey];
}
}

最后生成共享密钥:

1
2
3
4
5
6
7
8
9
10
package me.ihainan.algorithms

class DHKeyExchangeAlgorithm {
// ...
def generateSharedSecret(): Unit = {
keyAgreement.init(clientDHPrivateKey)
keyAgreement.doPhase(serverDHPublicKey, true)
_shared_secret = keyAgreement.generateSecret()
}
}

密钥交换阶段 —— 验证服务端

接下来客户端要根据收到的报文以及生成的共享密钥,来验证服务端的身份以及共享密钥的正确性。具体的步骤前面已经讲过,直接看代码吧。需要注意这个过程中生成的摘要(SSH 协议称之为交换哈希,exchange hash,用 H 表示)后面还会被我们用到,所以会把它存到 SSHSession 中。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
package me.ihainan.algorithms

import java.io.ByteArrayOutputStream
import java.math.BigInteger
import java.nio.charset.StandardCharsets
import java.security._

import java.io.ByteArrayOutputStream
import java.math.BigInteger
import java.nio.charset.StandardCharsets
import java.security._
import java.io.ByteArrayInputStream
import java.io.DataInputStream
import me.ihainan.utils.SSHBuffer
import me.ihainan.algorithms
import me.ihainan.SSHSession._
import java.security.spec.RSAPublicKeySpec
import me.ihainan.utils.SSHBufferReader
import me.ihainan.SSHSession
import org.slf4j.LoggerFactory

object SSHSignatureVerifier {
private val logger = LoggerFactory.getLogger(getClass().getName())

def verifySignature(signature: Array[Byte]): Boolean = {
// V_C || V_S || I_C || I_S || K_S || e || f || K
val sha = MessageDigest.getInstance("SHA-256")
val buffer = new SSHBuffer()
buffer.putString(getClientVersion)
buffer.putString(getServerVersion)
buffer.putByteArrayWithLength(getIC())
buffer.putByteArrayWithLength(getIS())
buffer.putByteArrayWithLength(getKS())
buffer.putMPInt(getE().toByteArray())
buffer.putMPInt(getF())
buffer.putMPInt(getK())
sha.update(buffer.getData, 0, buffer.getData.length)

val H = sha.digest()
SSHSession.setH(H)

val sig = Signature.getInstance("SHA512withRSA")
sig.initVerify(getServerRSAPublicKey());
sig.update(H);
val sigBuffer = new SSHBufferReader(signature)
val sigName = sigBuffer.getString // rsa-sha2-512
val sigLength = sigBuffer.getInt() // 256
val finalSignature = sigBuffer.getByteArray(sigLength)
val result = sig.verify(finalSignature)
if (!result) {
throw new Exception("Signature verification failed")
}
result
}
}

编写我们的 DiffieHellmanGroup14Packet 类来发送和接受密钥交换报文:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
package me.ihainan.packets

import java.lang
import java.io.InputStream
import java.nio.ByteBuffer
import me.ihainan.utils.SSHBuffer
import me.ihainan.utils.SSHStreamBufferReader
import me.ihainan.utils.SSHBufferReader
import java.math.BigInteger
import me.ihainan.SSHSession
import me.ihainan.algorithms.RSAAlgorithm
import me.ihainan.algorithms.SSHSignatureVerifier
import me.ihainan.algorithms.AES256CTR.logger
import org.slf4j.LoggerFactory

object DiffieHellmanGroup14Packet {
private val logger = LoggerFactory.getLogger(getClass().getName())

private val DH_EXCHANGE_CODE = 0x1e.toByte

private def generatePayload(): SSHBuffer = {
val buffer = new SSHBuffer()
buffer.putByte(DH_EXCHANGE_CODE)
val clientE = SSHSession.keyExchangeAlgorithm.clientE
buffer.putMPInt(clientE)
buffer
}

def generateDHInitPacket(): SSHBuffer = {
val payloadBuffer = generatePayload()
val buffer = payloadBuffer.wrapWithPadding()
buffer
}

def readServerPublibKeyFromInputStream(in: InputStream): Unit = {
val streamReader = new SSHStreamBufferReader(in)
val reader = streamReader.reader
val packetLength = streamReader.packetLength
val paddingLength = reader.getByte()
val payloadBytes = reader.getByteArray(packetLength - paddingLength - 1)
val payloadReader = new SSHBufferReader(payloadBytes)

// read payload
val command = payloadReader.getByte() // SSH_MSG_KEXDH_REPLY
logger.debug(s" packet command = $command")

// server public host key and certificates (K_S)
val ks = payloadReader.getByteArray()
val hostKeyReader = new SSHBufferReader(ks)
val hostKeyType = hostKeyReader.getString()
val serverRSAE = new BigInteger(hostKeyReader.getMPInt())
val serverRSAN = new BigInteger(hostKeyReader.getMPInt())

// f
val serverDHF = payloadReader.getMPInt()

// signature of H
val signatureBytes = payloadReader.getByteArray()

// Generate server's public key
val serverRSAPublicKey = RSAAlgorithm.generateRSAPublicKey(serverRSAE, serverRSAN)
SSHSession.setServerRSAPublicKey(serverRSAPublicKey)

// Save into SSHSession
SSHSession.setF(serverDHF)
SSHSession.setKS(ks)

// validate the signature
SSHSignatureVerifier.verifySignature(signatureBytes)

// derive keys and initialize ciphers
SSHSession.derivateKeys()
}
}

并在 SimpleSSHClient.start() 方法中调用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class SimpleSSHClient(...) {
def start(): Unit = {
// ...
// sends public key to the server
clientKEX()

// receive server's public to generate shared secret
serverKEX()
// ...
}

private def clientKEX(): Unit = {
write(DiffieHellmanGroup14Packet.generateDHInitPacket().getData)
}

private def serverKEX(): Unit = {
DiffieHellmanGroup14Packet.readServerPublibKeyFromInputStream(in)
}
}

密钥交换阶段 —— 会话密钥

在正式加密数据之前,我们还需要使用上面步骤生成的共享密钥(K)和交换哈希(H)衍生出多个会话密钥用于后面数据的加解密MAC 验证,以及会引入 Session ID 的概念。

会话密钥的衍生规定在 RFC-4253 7.2 小节 中,我们会衍生出六个密钥,分别是:

  • 客户端到服务端数据加密的初始向量(IVc2s):用于初始化加密算法(比如我使用的 AES-256-CFB 算法)。初始向量能提供额外的随机性以增强安全性。
  • 服务端到客户端数据加密的初始向量(IVs2c)
  • 客户端到服务器的数据加密密钥(Kc2s):加密算法会用到的加密密钥。
  • 服务器到客户端的数据加密密钥(Ks2c)
  • 客户端到服务器的数据流的消息认证码密钥(MACKc2s):生成消息验证码(MAC)的时候需要的密钥。
  • 服务器到客户端的数据流的消息认证码密钥(MACKs2c)

这些密钥的生成遵循相同的算法,唯一的区别是其中一个**字符参数 C**,上面的六个密钥,对应的字符分别是英文字符的 A, B, C, D, E, F 的 ASCII 值(一个字节)。

1
HASH(K || H || C || session_id)

计算过程还涉及到了 Session ID,客户端和服务端的一个单独连接即为一个会话(Session),所以每个会话需要一个唯一的 ID 值。在一般实现过程中,我们往往使用交换哈希 H 来作为当前会话的标识。

对应的代码实现如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
object SSHSession {
// https://datatracker.ietf.org/doc/html/rfc4253#section-7.2
private def derivateKey(c: Char): Array[Byte] = {
val buffer = new SSHBuffer()
buffer.putMPInt(SSHSession.getK())
buffer.putByteArray(SSHSession.getH())
buffer.putByte(c.toByte)
buffer.putByteArray(SSHSession.getSessionID)
SHA256.computHash(buffer.getData).toArray
}

private var _IVc2s: Array[Byte] = _
private var _IVs2c: Array[Byte] = _
private var _Kc2s: Array[Byte] = _
private var _Ks2c: Array[Byte] = _
private var _MACKc2s: Array[Byte] = _
private var _MACKs2c: Array[Byte] = _

def derivateKeys(): Unit = {
logger.info("Derivating keys...")
_IVc2s = derivateKey('A')
_IVs2c = derivateKey('B')
_Kc2s = derivateKey('C')
_Ks2c = derivateKey('D')
_MACKc2s = derivateKey('E')
_MACKs2c = derivateKey('F')

// ...
}
}

在最后,我们要基于上面得到的六个密钥,构建用于数据加解密和验证的类。AES256CTR 类做数据加解密,HMACSHA1 类用于数据校验。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
package me.ihainan.algorithms

import javax.crypto.Cipher
import javax.crypto.spec.{IvParameterSpec, SecretKeySpec}
import me.ihainan.SSHSession
import me.ihainan.utils.SSHFormatter
import org.slf4j.LoggerFactory

class AES256CTR(mode: Int, key: Array[Byte], iv: Array[Byte]) {
private val logger = LoggerFactory.getLogger(getClass().getName())

private val ivSize = 16
private val bSize = 32
private var cipher: Cipher = _

val pad = "NoPadding"
var tmpIv = iv
var tmpKey = key

if (iv.length > ivSize) {
tmpIv = new Array[Byte](ivSize)
System.arraycopy(iv, 0, tmpIv, 0, ivSize)
}

if (key.length > bSize) {
tmpKey = new Array[Byte](bSize)
System.arraycopy(key, 0, tmpKey, 0, bSize)
}

try {
val keySpec = new SecretKeySpec(tmpKey, "AES")
cipher = Cipher.getInstance(s"AES/CTR/$pad")
cipher.init(
if (mode == Cipher.ENCRYPT_MODE) Cipher.ENCRYPT_MODE else Cipher.DECRYPT_MODE,
keySpec,
new IvParameterSpec(tmpIv)
)
} catch {
case e: Exception =>
cipher = null
throw e
}

def update(input: Array[Byte],
inputOffset: Int,
inputLen: Int,
output: Array[Byte],
outputOffset: Int): Unit = {
cipher.update(input, inputOffset, inputLen, output, outputOffset)
}
}

object AES256CTR {
private val logger = LoggerFactory.getLogger(getClass().getName())

def encrypt(plainText: Array[Byte]): Array[Byte] = {
logger.debug(" Plain text: " + SSHFormatter.formatByteArray(plainText))
val cipherText = new Array[Byte](plainText.length)
SSHSession.getAESEncrypt.update(plainText, 0, plainText.length, cipherText, 0)
logger.debug(" Encrypted text: " + SSHFormatter.formatByteArray(cipherText))
cipherText
}

def decrypt(cipherText: Array[Byte]): Array[Byte] = {
val decryptedText = new Array[Byte](cipherText.length)
SSHSession.getAESDecrypt.update(cipherText, 0, cipherText.length, decryptedText, 0)
decryptedText
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
package me.ihainan.algorithms

import javax.crypto.spec.SecretKeySpec
import javax.crypto.Mac
import javax.crypto.ShortBufferException
import me.ihainan.SSHSession
import me.ihainan.utils.SSHFormatter
import org.slf4j.LoggerFactory

class HMACSHA1(key: Array[Byte]) {
private val logger = LoggerFactory.getLogger(getClass().getName())

private val bsize = 20
def getBlockSize: Int = bsize
val tmp = new Array[Byte](4)

val tmpKey = if (key.length > bsize) {
val tmp = new Array[Byte](bsize)
System.arraycopy(key, 0, tmp, 0, bsize)
tmp
} else {
key
}
val keyspec = new SecretKeySpec(tmpKey, "HmacSHA1")
val mac = Mac.getInstance("HmacSHA1")
mac.init(keyspec)

def update(i: Int): Unit = {
tmp(0) = (i >>> 24).toByte
tmp(1) = (i >>> 16).toByte
tmp(2) = (i >>> 8).toByte
tmp(3) = i.toByte
update(tmp, 0, 4)
}

def update(foo: Array[Byte], s: Int, l: Int): Unit = {
mac.update(foo, s, l)
}

def doFinal(buf: Array[Byte], offset: Int): Unit = {
try {
mac.doFinal(buf, offset)
} catch {
case _: ShortBufferException => // Handle the exception if needed
}
}
}

object HMACSHA1 {
private val logger = LoggerFactory.getLogger(getClass().getName())

def generateMAC(data: Array[Byte]): Array[Byte] = {
val hmacClientToServer = SSHSession.getHMACClientToServer()
hmacClientToServer.update(SSHSession.getClientSeqNum())

hmacClientToServer.update(data, 0, data.length)
val macClientToServer = new Array[Byte](hmacClientToServer.getBlockSize)
hmacClientToServer.doFinal(macClientToServer, 0)
logger.debug(" generated MAC = " + SSHFormatter.formatByteArray(macClientToServer))
SSHSession.addClientSeqNum()
macClientToServer
}

def validateMAC(data: Array[Byte], mac: Array[Byte]): Unit = {
val hmacVerify = SSHSession.getHMACServerToClient()
hmacVerify.update(SSHSession.getServerSeqNum())
SSHSession.addServerSeqNum()
hmacVerify.update(data, 0, data.length)
val macToVerify = new Array[Byte](hmacVerify.getBlockSize)
hmacVerify.doFinal(macToVerify, 0)
logger.debug(" macToVerify = " + SSHFormatter.formatByteArray(macToVerify))
val valid = mac.sameElements(macToVerify)
if (!valid) {
throw new Exception("HMAC validation failed")
}
}
}

在刚才的 SSHSession.derivateKeys 方法的末尾初始化两个类的四个实例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
object SSHSession {
private var _aesEncrypt: AES256CTR = _
private var _aesDecrypt: AES256CTR = _
private var _hmacClientToServer: HMACSHA1 = _
private var _hmacServerToClient: HMACSHA1 = _

def derivateKeys(): Unit = {
// ...
_aesEncrypt = new AES256CTR(Cipher.ENCRYPT_MODE, _Kc2s, _IVc2s)
_aesDecrypt = new AES256CTR(Cipher.DECRYPT_MODE, _Ks2c, _IVs2c)

_hmacClientToServer = new HMACSHA1(_MACKc2s)
_hmacServerToClient = new HMACSHA1(_MACKs2c)
}
}

启用加密

在密钥交换完毕之后,双方会向对方发送一个 NEW KEYS 报文,通知对方从现在开始,我这边发送的报文都会启用加密。抓包如下:

new_keys

该报文定义在 RFC-4253 Section 7.3 - Taking Keys Into Use 中,结构非常简单。

  • 消息编号(message code):1 字节,固定为 SSH_MSG_NEWKEYS,即 0x15

代码实现在 NewKeysPacket 类中。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
package me.ihainan.packets

import java.io.InputStream
import me.ihainan.utils.SSHBuffer
import me.ihainan.utils.SSHBufferReader
import me.ihainan.utils.SSHStreamBufferReader

object NewKeysPacket {

val SSH_MSG_NEWKEYS = 0x15.toByte

def generatePacket(): Array[Byte] = {
val buffer = new SSHBuffer()
buffer.putByte(SSH_MSG_NEWKEYS)
buffer.wrapWithPadding().getData
}

def readNewKeysFromInputStream(in: InputStream): Unit = {
val reader = new SSHStreamBufferReader(in)
val payloadBuffer = reader.reader
val paddingLength = payloadBuffer.getByte()
val newKeyCode = payloadBuffer.getByte()
if (newKeyCode != SSH_MSG_NEWKEYS) {
throw new Exception("The received code is not NEW_KEYS")
}
}
}

SimpleSSHClient 中调用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class SimpleSSHClient(...) {
def start(): Unit = {
// ...
// client sends/receives the NEW KEYS packet to/from the server
sendNewKey()
receiveNewKey()
// ...
}

private def sendNewKey(): Unit = {
logger.info("Sending NEW_KEY packet...")
write(NewKeysPacket.generatePacket())
}

private def receiveNewKey(): Unit = {
logger.info("Receving NEW_KEY...")
NewKeysPacket.readNewKeysFromInputStream(in)
}
}

至此,协商阶段结束。下一篇我们会开始尝试数据加解密和报文验证。