Skip to content

Commit 6024b06

Browse files
krutonclaude
andcommitted
fix(security): enforce negotiated algorithm in host key signature verification
A server could send a signature blob claiming algorithm "ssh-rsa" (SHA-1) even when the client negotiated "rsa-sha2-256" or "rsa-sha2-512", causing the client to verify with the weaker SHA-1 hash. Per RFC 8332 §3.2, the algorithm identifier in the signature blob must match what was negotiated. SignatureVerifier.verify() now requires the expected algorithm and rejects mismatches. Agent session binding uses a separate verifyWithKeyType() path that validates the sig algorithm is compatible with the host key type, guarding against cross-type forgery in that context. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 3260bba commit 6024b06

4 files changed

Lines changed: 183 additions & 5 deletions

File tree

sshlib/src/main/kotlin/org/connectbot/sshlib/client/AgentProtocolHandler.kt

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
/*
22
* ConnectBot SSH Library
3-
* Copyright 2025 Kenny Root
3+
* Copyright 2025-2026 Kenny Root
44
*
55
* Licensed under the Apache License, Version 2.0 (the "License");
66
* you may not use this file except in compliance with the License.
@@ -135,7 +135,7 @@ internal class AgentProtocolHandler(
135135
private val provider: AgentProvider,
136136
private val sessionInfo: AgentSessionInfo,
137137
private val bindVerifier: SessionBindVerifier = SessionBindVerifier { hk, sig, data ->
138-
SignatureVerifier.verify(hk, sig, data)
138+
SignatureVerifier.verifyWithKeyType(hk, sig, data)
139139
},
140140
) {
141141
companion object {

sshlib/src/main/kotlin/org/connectbot/sshlib/client/SshConnection.kt

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1329,7 +1329,9 @@ class SshConnection(
13291329
}
13301330
serverHostKeyBlob = serverHostKey
13311331

1332-
if (!SignatureVerifier.verify(serverHostKey, signature, hash)) {
1332+
val negotiatedAlg = negotiatedHostKeyAlgorithm
1333+
?: throw SshException("No host key algorithm negotiated")
1334+
if (!SignatureVerifier.verify(serverHostKey, signature, hash, negotiatedAlg)) {
13331335
logger.error("Server signature verification failed")
13341336
throw SshException("Server signature verification failed")
13351337
}

sshlib/src/main/kotlin/org/connectbot/sshlib/crypto/SignatureVerifier.kt

Lines changed: 43 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
/*
22
* ConnectBot SSH Library
3-
* Copyright 2025 Kenny Root
3+
* Copyright 2025-2026 Kenny Root
44
*
55
* Licensed under the Apache License, Version 2.0 (the "License");
66
* you may not use this file except in compliance with the License.
@@ -23,10 +23,22 @@ import org.connectbot.sshlib.protocol.SshSignature
2323

2424
internal object SignatureVerifier {
2525

26-
fun verify(serverHostKey: ByteArray, signatureData: ByteArray, exchangeHash: ByteArray): Boolean {
26+
/**
27+
* Verifies the server's host key signature during KEX.
28+
*
29+
* Enforces that the algorithm name in the signature blob matches [expectedAlgorithm]
30+
* (the negotiated host key algorithm), per RFC 8332 §3.2.
31+
*/
32+
fun verify(serverHostKey: ByteArray, signatureData: ByteArray, exchangeHash: ByteArray, expectedAlgorithm: String): Boolean {
2733
val sig = SshSignature(ByteBufferKaitaiStream(signatureData))
2834
sig._read()
2935

36+
// RFC 8332 §3.2: reject if the algorithm in the signature blob doesn't match
37+
// what was negotiated. Prevents a server from downgrading e.g. rsa-sha2-256 → ssh-rsa.
38+
if (sig.algorithmName() != expectedAlgorithm) {
39+
return false
40+
}
41+
3042
val pubKey = SshPublicKey(ByteBufferKaitaiStream(serverHostKey))
3143
pubKey._read()
3244

@@ -35,4 +47,33 @@ internal object SignatureVerifier {
3547

3648
return algorithm.verify(pubKey, sig, exchangeHash)
3749
}
50+
51+
/**
52+
* Verifies a signature where the algorithm is self-described in the signature blob
53+
* (e.g., agent session binding). The algorithm must be compatible with the key type
54+
* of [hostKey].
55+
*/
56+
fun verifyWithKeyType(hostKey: ByteArray, signatureData: ByteArray, data: ByteArray): Boolean {
57+
val sig = SshSignature(ByteBufferKaitaiStream(signatureData))
58+
sig._read()
59+
60+
val pubKey = SshPublicKey(ByteBufferKaitaiStream(hostKey))
61+
pubKey._read()
62+
63+
val sigAlgorithm = sig.algorithmName()
64+
val keyType = pubKey.algorithmName()
65+
66+
// Ensure the sig algorithm is compatible with the key type to prevent cross-type forgery.
67+
if (!isAlgorithmCompatibleWithKeyType(sigAlgorithm, keyType)) {
68+
return false
69+
}
70+
71+
val algorithm = SignatureEntry.fromSshName(sigAlgorithm)?.algorithm ?: return false
72+
return algorithm.verify(pubKey, sig, data)
73+
}
74+
75+
private fun isAlgorithmCompatibleWithKeyType(sigAlgorithm: String, keyType: String): Boolean = when (keyType) {
76+
"ssh-rsa" -> sigAlgorithm in setOf("ssh-rsa", "rsa-sha2-256", "rsa-sha2-512")
77+
else -> sigAlgorithm == keyType
78+
}
3879
}
Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
/*
2+
* ConnectBot SSH Library
3+
* Copyright 2025-2026 Kenny Root
4+
*
5+
* Licensed under the Apache License, Version 2.0 (the "License");
6+
* you may not use this file except in compliance with the License.
7+
* You may obtain a copy of the License at
8+
*
9+
* http://www.apache.org/licenses/LICENSE-2.0
10+
*
11+
* Unless required by applicable law or agreed to in writing, software
12+
* distributed under the License is distributed on an "AS IS" BASIS,
13+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
* See the License for the specific language governing permissions and
15+
* limitations under the License.
16+
*/
17+
18+
package org.connectbot.sshlib.crypto
19+
20+
import org.junit.jupiter.api.Test
21+
import java.io.ByteArrayOutputStream
22+
import java.io.DataOutputStream
23+
import java.math.BigInteger
24+
import java.security.KeyPairGenerator
25+
import java.security.Signature
26+
import java.security.interfaces.RSAPublicKey
27+
import kotlin.test.assertFalse
28+
import kotlin.test.assertTrue
29+
30+
class SignatureVerifierTest {
31+
32+
private fun encodeString(out: DataOutputStream, value: ByteArray) {
33+
out.writeInt(value.size)
34+
out.write(value)
35+
}
36+
37+
private fun encodeString(out: DataOutputStream, value: String) = encodeString(out, value.toByteArray(Charsets.US_ASCII))
38+
39+
private fun encodeMpint(out: DataOutputStream, value: BigInteger) {
40+
val bytes = value.toByteArray()
41+
out.writeInt(bytes.size)
42+
out.write(bytes)
43+
}
44+
45+
private fun buildRsaHostKey(pub: RSAPublicKey): ByteArray {
46+
val buf = ByteArrayOutputStream()
47+
val out = DataOutputStream(buf)
48+
encodeString(out, "ssh-rsa")
49+
encodeMpint(out, pub.publicExponent)
50+
encodeMpint(out, pub.modulus)
51+
return buf.toByteArray()
52+
}
53+
54+
private fun buildSignatureBlob(algorithmName: String, sigBytes: ByteArray): ByteArray {
55+
val buf = ByteArrayOutputStream()
56+
val out = DataOutputStream(buf)
57+
encodeString(out, algorithmName)
58+
encodeString(out, sigBytes)
59+
return buf.toByteArray()
60+
}
61+
62+
private fun signData(data: ByteArray, jcaAlgorithm: String, kp: java.security.KeyPair): ByteArray {
63+
val sig = Signature.getInstance(jcaAlgorithm)
64+
sig.initSign(kp.private)
65+
sig.update(data)
66+
return sig.sign()
67+
}
68+
69+
@Test
70+
fun `accepts rsa-sha2-256 signature when rsa-sha2-256 was negotiated`() {
71+
val kp = KeyPairGenerator.getInstance("RSA").apply { initialize(2048) }.generateKeyPair()
72+
val data = "exchange hash".toByteArray()
73+
val sigBytes = signData(data, "SHA256withRSA", kp)
74+
val hostKey = buildRsaHostKey(kp.public as RSAPublicKey)
75+
val sigBlob = buildSignatureBlob("rsa-sha2-256", sigBytes)
76+
77+
assertTrue(SignatureVerifier.verify(hostKey, sigBlob, data, "rsa-sha2-256"))
78+
}
79+
80+
@Test
81+
fun `accepts rsa-sha2-512 signature when rsa-sha2-512 was negotiated`() {
82+
val kp = KeyPairGenerator.getInstance("RSA").apply { initialize(2048) }.generateKeyPair()
83+
val data = "exchange hash".toByteArray()
84+
val sigBytes = signData(data, "SHA512withRSA", kp)
85+
val hostKey = buildRsaHostKey(kp.public as RSAPublicKey)
86+
val sigBlob = buildSignatureBlob("rsa-sha2-512", sigBytes)
87+
88+
assertTrue(SignatureVerifier.verify(hostKey, sigBlob, data, "rsa-sha2-512"))
89+
}
90+
91+
@Test
92+
fun `rejects ssh-rsa signature blob when rsa-sha2-256 was negotiated`() {
93+
val kp = KeyPairGenerator.getInstance("RSA").apply { initialize(2048) }.generateKeyPair()
94+
val data = "exchange hash".toByteArray()
95+
// Server signs correctly with SHA-1, but client negotiated SHA-256
96+
val sigBytes = signData(data, "SHA1withRSA", kp)
97+
val hostKey = buildRsaHostKey(kp.public as RSAPublicKey)
98+
val sigBlob = buildSignatureBlob("ssh-rsa", sigBytes)
99+
100+
assertFalse(SignatureVerifier.verify(hostKey, sigBlob, data, "rsa-sha2-256"))
101+
}
102+
103+
@Test
104+
fun `rejects rsa-sha2-256 signature blob when rsa-sha2-512 was negotiated`() {
105+
val kp = KeyPairGenerator.getInstance("RSA").apply { initialize(2048) }.generateKeyPair()
106+
val data = "exchange hash".toByteArray()
107+
val sigBytes = signData(data, "SHA256withRSA", kp)
108+
val hostKey = buildRsaHostKey(kp.public as RSAPublicKey)
109+
val sigBlob = buildSignatureBlob("rsa-sha2-256", sigBytes)
110+
111+
assertFalse(SignatureVerifier.verify(hostKey, sigBlob, data, "rsa-sha2-512"))
112+
}
113+
114+
@Test
115+
fun `rejects rsa-sha2-512 signature blob when rsa-sha2-256 was negotiated`() {
116+
val kp = KeyPairGenerator.getInstance("RSA").apply { initialize(2048) }.generateKeyPair()
117+
val data = "exchange hash".toByteArray()
118+
val sigBytes = signData(data, "SHA512withRSA", kp)
119+
val hostKey = buildRsaHostKey(kp.public as RSAPublicKey)
120+
val sigBlob = buildSignatureBlob("rsa-sha2-512", sigBytes)
121+
122+
assertFalse(SignatureVerifier.verify(hostKey, sigBlob, data, "rsa-sha2-256"))
123+
}
124+
125+
@Test
126+
fun `rejects completely wrong algorithm name in signature blob`() {
127+
val kp = KeyPairGenerator.getInstance("RSA").apply { initialize(2048) }.generateKeyPair()
128+
val data = "exchange hash".toByteArray()
129+
val sigBytes = signData(data, "SHA256withRSA", kp)
130+
val hostKey = buildRsaHostKey(kp.public as RSAPublicKey)
131+
val sigBlob = buildSignatureBlob("unknown-algo", sigBytes)
132+
133+
assertFalse(SignatureVerifier.verify(hostKey, sigBlob, data, "rsa-sha2-256"))
134+
}
135+
}

0 commit comments

Comments
 (0)