+ * Different public key algorithms (RSA, DSS, ECDSA, etc.) have different structures. This method
+ * decodes the algorithm-specific format and returns the components needed for cryptographic
+ * operations.
+ *
+ *
+ * @param certificatePublicKey the raw byte array of the public key blob.
+ * @return A 2D byte array where each inner array is a component of the public key (e.g., for RSA,
+ * it returns {exponent, modulus}).
+ * @throws Exception if the public key algorithm is unknown or the key format is corrupt.
+ */
+ public static byte[][] parsePublicKey(byte[] certificatePublicKey) throws Exception {
+ Buffer buffer = new Buffer(certificatePublicKey);
+ String algorithm = byte2str(buffer.getString());
+
+ if (algorithm.startsWith("ssh-rsa") || algorithm.startsWith("rsa-")) {
+ byte[] ee = buffer.getMPInt();
+ byte[] n = buffer.getMPInt();
+ return new byte[][] {ee, n};
+ }
+
+ if (algorithm.startsWith("ssh-dss")) {
+ byte[] p = buffer.getMPInt();
+ byte[] q = buffer.getMPInt();
+ byte[] g = buffer.getMPInt();
+ byte[] y = buffer.getMPInt();
+ return new byte[][] {y, p, q, g};
+ }
+
+ if (algorithm.startsWith("ecdsa-sha2-")) {
+ // https://www.rfc-editor.org/rfc/rfc5656#section-3.1
+ // The string [identifier] is the identifier of the elliptic curve domain parameters.
+ String identifier = byte2str(buffer.getString());
+ int len = buffer.getInt();
+ int x04 = buffer.getByte();
+ byte[] r = new byte[(len - 1) / 2];
+ byte[] s = new byte[(len - 1) / 2];
+ buffer.getByte(r);
+ buffer.getByte(s);
+ return new byte[][] {r, s};
+ }
+
+ if (algorithm.startsWith("ssh-ed25519") || algorithm.startsWith("ssh-ed448")) {
+ int keyLength = buffer.getInt();
+ byte[] edXXX_pub_array = new byte[keyLength];
+ buffer.getByte(edXXX_pub_array);
+ return new byte[][] {edXXX_pub_array};
+ }
+ throw new JSchUnknownPublicKeyAlgorithmException(
+ "Unknown algorithm '" + algorithm.trim() + "'");
+ }
+
+ /**
+ * Verifies the cryptographic signature of the certificate.
+ *
+ * This method ensures that the certificate was actually signed by the private key corresponding
+ * to the public key of the Certificate Authority.
+ *
+ *
+ * @param certificate the certificate to verify.
+ * @param caPublicKeyAlgorithm the algorithm of the CA's public key.
+ * @throws Exception if the signature algorithm does not match the CA key algorithm or if the
+ * signature is cryptographically invalid.
+ */
+ private static void checkSignature(OpenSshCertificate certificate, String caPublicKeyAlgorithm)
+ throws Exception {
+ // Check signature
+ SignatureWrapper signature = getSignatureWrapper(certificate, caPublicKeyAlgorithm);
+ byte[][] publicKey = parsePublicKey(certificate.getSignatureKey());
+
+ signature.init();
+ signature.setPubKey(publicKey);
+ signature.update(certificate.getMessage());
+
+ if (!signature.verify(certificate.getSignature())) {
+ throw new JSchInvalidHostCertificateException(
+ "rejected HostKey: signature verification failed");
+ }
+ }
+
+ /**
+ * Creates and validates a {@link SignatureWrapper} for the certificate.
+ *
+ * This helper method extracts the signature algorithm from the certificate and verifies that it
+ * matches the algorithm of the signing CA's key.
+ *
+ *
+ * @param certificate the OpenSSH certificate.
+ * @param caPublicKeyAlgorithm the expected public key algorithm of the CA.
+ * @return a configured {@link SignatureWrapper} instance.
+ * @throws JSchException if the signature algorithm does not match the CA's key algorithm, or if
+ * the wrapper cannot be instantiated.
+ */
+ private static SignatureWrapper getSignatureWrapper(OpenSshCertificate certificate,
+ String caPublicKeyAlgorithm) throws JSchException {
+ byte[] certificateSignature = certificate.getSignature();
+ Buffer signatureBuffer = new Buffer(certificateSignature);
+ String signatureAlgorithm = byte2str(signatureBuffer.getString());
+
+ if (!caPublicKeyAlgorithm.equals(signatureAlgorithm)) {
+ throw new JSchInvalidHostCertificateException(
+ "rejected HostKey: signature verification failed, " + "signature algorithm: '"
+ + signatureAlgorithm + "' - CA public Key algorithm: '" + caPublicKeyAlgorithm + "'");
+ }
+
+ return new SignatureWrapper(signatureAlgorithm);
+ }
+
+ /**
+ * Retrieves all trusted Certificate Authority (CA) host keys from the repository.
+ *
+ * Trusted CAs are identified in the {@code known_hosts} file by the {@code @cert-authority}
+ * marker.
+ *
+ *
+ * @param knownHosts the repository of known hosts (typically from a known_hosts file).
+ * @return a {@link Set} of {@link HostKey} objects representing the trusted CAs.
+ * @throws Exception if there is an error accessing the host key repository.
+ */
+ private static Set getTrustedCAs(HostKeyRepository knownHosts) throws Exception {
+ HostKey[] hostKeys = knownHosts.getHostKey();
+ return hostKeys == null ? new HashSet<>()
+ : Arrays.stream(hostKeys).filter(OpenSshCertificateUtil::isKnownHostCaPublicKeyEntry)
+ .collect(Collectors.toSet());
+ }
+}
diff --git a/src/main/java/com/jcraft/jsch/OpensshCertificateParser.java b/src/main/java/com/jcraft/jsch/OpenSshCertificateParser.java
similarity index 65%
rename from src/main/java/com/jcraft/jsch/OpensshCertificateParser.java
rename to src/main/java/com/jcraft/jsch/OpenSshCertificateParser.java
index a4268b471..6daff6274 100644
--- a/src/main/java/com/jcraft/jsch/OpensshCertificateParser.java
+++ b/src/main/java/com/jcraft/jsch/OpenSshCertificateParser.java
@@ -2,24 +2,23 @@
import com.jcraft.jsch.JSch.InstanceLogger;
-import java.io.IOException;
-import java.security.NoSuchAlgorithmException;
import java.util.Collection;
+import static com.jcraft.jsch.OpenSshCertificateAwareIdentityFile.ECDSA_SHA2_NISTP256_CERT;
import static com.jcraft.jsch.OpenSshCertificateAwareIdentityFile.ECDSA_SHA2_NISTP256_CERT_V01_AT_OPENSSH_DOT_COM;
+import static com.jcraft.jsch.OpenSshCertificateAwareIdentityFile.ECDSA_SHA2_NISTP384_CERT;
import static com.jcraft.jsch.OpenSshCertificateAwareIdentityFile.ECDSA_SHA2_NISTP384_CERT_V01_AT_OPENSSH_DOT_COM;
+import static com.jcraft.jsch.OpenSshCertificateAwareIdentityFile.ECDSA_SHA2_NISTP521_CERT;
import static com.jcraft.jsch.OpenSshCertificateAwareIdentityFile.ECDSA_SHA2_NISTP521_CERT_V01_AT_OPENSSH_DOT_COM;
-import static com.jcraft.jsch.OpenSshCertificateAwareIdentityFile.RSA_SHA2_256_CERT_V01_AT_OPENSSH_DOT_COM;
-import static com.jcraft.jsch.OpenSshCertificateAwareIdentityFile.RSA_SHA2_512_CERT_V01_AT_OPENSSH_DOT_COM;
+import static com.jcraft.jsch.OpenSshCertificateAwareIdentityFile.SSH_DSS_CERT;
import static com.jcraft.jsch.OpenSshCertificateAwareIdentityFile.SSH_DSS_CERT_V01_AT_OPENSSH_DOT_COM;
+import static com.jcraft.jsch.OpenSshCertificateAwareIdentityFile.SSH_ED25519_CERT;
import static com.jcraft.jsch.OpenSshCertificateAwareIdentityFile.SSH_ED25519_CERT_V01_AT_OPENSSH_DOT_COM;
+import static com.jcraft.jsch.OpenSshCertificateAwareIdentityFile.SSH_ED448_CERT;
import static com.jcraft.jsch.OpenSshCertificateAwareIdentityFile.SSH_ED448_CERT_V01_AT_OPENSSH_DOT_COM;
+import static com.jcraft.jsch.OpenSshCertificateAwareIdentityFile.SSH_RSA_CERT;
import static com.jcraft.jsch.OpenSshCertificateAwareIdentityFile.SSH_RSA_CERT_V01_AT_OPENSSH_DOT_COM;
-import static com.jcraft.jsch.OpenSshCertificateUtil.extractKeyData;
-import static com.jcraft.jsch.OpenSshCertificateUtil.extractKeyType;
-import static com.jcraft.jsch.OpenSshCertificateUtil.toDateString;
import static com.jcraft.jsch.OpenSshCertificateUtil.trimToEmptyIfNull;
-import static com.jcraft.jsch.Util.fromBase64;
import static java.nio.charset.StandardCharsets.UTF_8;
/**
@@ -41,29 +40,7 @@
* @see OpenSSH Certificate
* Protocol
*/
-class OpensshCertificateParser {
-
- private final String keyType;
-
- private final OpenSshCertificateBuffer buffer;
-
- private final InstanceLogger instLogger;
-
- public OpensshCertificateParser(InstanceLogger instLogger, String certificate)
- throws JSchException {
- this.instLogger = instLogger;
-
- this.keyType = extractKeyType(certificate);
-
- // Decode
- String base64 = extractKeyData(certificate);
-
-
- byte[] keyData = fromBase64(base64.getBytes(UTF_8), 0, base64.getBytes(UTF_8).length);
-
- buffer = new OpenSshCertificateBuffer(keyData);
- }
-
+class OpenSshCertificateParser {
/**
* Parses the certificate data and returns a complete {@link OpenSshCertificate} object.
*
@@ -88,27 +65,24 @@ public OpensshCertificateParser(InstanceLogger instLogger, String certificate)
* Signature
*
*
+ * @param instLogger logger instance for debugging
+ * @param certificateData the certificate data
* @return the parsed certificate object
- * @throws IOException if an I/O error occurs during parsing
* @throws JSchException if the certificate format is invalid or unsupported
- * @throws NoSuchAlgorithmException if a required cryptographic algorithm is not available
*/
- public OpenSshCertificate parse() throws IOException, JSchException, NoSuchAlgorithmException {
+ static OpenSshCertificate parse(InstanceLogger instLogger, byte[] certificateData)
+ throws JSchException {
+
+ OpenSshCertificateBuffer buffer = new OpenSshCertificateBuffer(certificateData);
OpenSshCertificate.Builder openSshCertificateBuilder = new OpenSshCertificate.Builder();
- // keyType
String kTypeFromData = trimToEmptyIfNull(buffer.getString(UTF_8));
- if (kTypeFromData.isEmpty() || !keyType.equals(kTypeFromData)) {
- instLogger.getLogger().log(Logger.WARN,
- "Key type declared does not correspond to the encoded key type: " + keyType + " - "
- + kTypeFromData);
- }
openSshCertificateBuilder.keyType(kTypeFromData).nonce(buffer.getString());
// KeyPair.parsePubkeyBlob expect keytype in public key blob
- KeyPair publicKey = parsePublicKey(keyType, buffer);
+ KeyPair publicKey = parsePublicKey(instLogger, kTypeFromData, buffer);
openSshCertificateBuilder.certificatePublicKey(publicKey.getPublicKeyBlob())
.serial(buffer.getLong()).type(buffer.getInt()).id(buffer.getString(UTF_8));
@@ -119,39 +93,58 @@ public OpenSshCertificate parse() throws IOException, JSchException, NoSuchAlgor
openSshCertificateBuilder.principals(principals).validAfter(buffer.getLong())
.validBefore(buffer.getLong()).criticalOptions(buffer.getCriticalOptions())
.extensions(buffer.getExtensions()).reserved(buffer.getString(UTF_8))
- .signatureKey(buffer.getString()).signature(buffer.getString());
+ .signatureKey(buffer.getString());
+ int messageEndIndex = buffer.s;
- OpenSshCertificate certificate = openSshCertificateBuilder.build();
+ //
+ byte[] message = new byte[messageEndIndex - 0];
+ System.arraycopy(buffer.buffer, 0, message, 0, messageEndIndex - 0);
- if (buffer.getReadPosition() != buffer.getWritePosition()) {
- throw new JSchException("Cannot read OpenSSH certificate, got more data than expected: "
- + buffer.getReadPosition() + ", actual: " + buffer.getWritePosition()
- + ". ID of the ca certificate: " + certificate.getId());
- }
+ openSshCertificateBuilder.message(message);
- if (!certificate.isValidNow()) {
- instLogger.getLogger().log(Logger.WARN,
- "certificate is not valid. Valid after: " + toDateString(certificate.getValidAfter())
- + " - Valid before: " + toDateString(certificate.getValidBefore()));
- }
+ openSshCertificateBuilder.signature(buffer.getString());
+ OpenSshCertificate certificate = openSshCertificateBuilder.build();
+
+ if (buffer.s != buffer.index) {
+ throw new JSchException(
+ "Cannot read OpenSSH certificate, got more data than expected: " + buffer.s + ", actual: "
+ + buffer.index + ". ID of the ca certificate: " + certificate.getId());
+ }
return certificate;
}
-
- private KeyPair parsePublicKey(String keyType, Buffer buffer) throws JSchException {
+ /**
+ * Parses a public key from a buffer based on the specified key type.
+ *
+ * This method is used to deserialize public key components from a binary buffer, typically from
+ * an SSH certificate or public key file. It uses a {@code switch} statement to handle different
+ * key types, including RSA, DSA, ECDSA, Ed25519, and Ed448, and their corresponding certificate
+ * variations. The method reads the necessary key components (e.g., modulus, exponent, curve
+ * parameters) from the buffer and uses them to construct the appropriate {@link KeyPair} object.
+ *
+ * @param instLogger An instance of {@link JSch.InstanceLogger} for logging.
+ * @param keyType The string identifier for the public key algorithm (e.g.,
+ * "ssh-rsa-cert-v01@openssh.com").
+ * @param buffer The {@link Buffer} containing the binary representation of the public key.
+ * @return A {@link KeyPair} object representing the parsed public key.
+ * @throws JSchException if the key type is unsupported or if there is an error parsing the key
+ * components from the buffer.
+ */
+ static KeyPair parsePublicKey(InstanceLogger instLogger, String keyType, Buffer buffer)
+ throws JSchException {
switch (keyType) {
case SSH_RSA_CERT_V01_AT_OPENSSH_DOT_COM:
- case RSA_SHA2_256_CERT_V01_AT_OPENSSH_DOT_COM:
- case RSA_SHA2_512_CERT_V01_AT_OPENSSH_DOT_COM:
+ case SSH_RSA_CERT:
byte[] pub_array = buffer.getMPInt(); // e
byte[] n_array = buffer.getMPInt(); // n
return new KeyPairRSA(instLogger, n_array, pub_array, null);
case SSH_DSS_CERT_V01_AT_OPENSSH_DOT_COM:
+ case SSH_DSS_CERT:
byte[] p_array = buffer.getMPInt();
byte[] q_array = buffer.getMPInt();
byte[] g_array = buffer.getMPInt();
@@ -161,6 +154,9 @@ private KeyPair parsePublicKey(String keyType, Buffer buffer) throws JSchExcepti
case ECDSA_SHA2_NISTP256_CERT_V01_AT_OPENSSH_DOT_COM:
case ECDSA_SHA2_NISTP384_CERT_V01_AT_OPENSSH_DOT_COM:
case ECDSA_SHA2_NISTP521_CERT_V01_AT_OPENSSH_DOT_COM:
+ case ECDSA_SHA2_NISTP256_CERT:
+ case ECDSA_SHA2_NISTP384_CERT:
+ case ECDSA_SHA2_NISTP521_CERT:
byte[] name = buffer.getString();
int len = buffer.getInt();
int x04 = buffer.getByte();
@@ -171,11 +167,13 @@ private KeyPair parsePublicKey(String keyType, Buffer buffer) throws JSchExcepti
return new KeyPairECDSA(instLogger, name, r_array, s_array, null);
case SSH_ED25519_CERT_V01_AT_OPENSSH_DOT_COM:
+ case SSH_ED25519_CERT:
byte[] ed25519_pub_array = new byte[buffer.getInt()];
buffer.getByte(ed25519_pub_array);
return new KeyPairEd25519(instLogger, ed25519_pub_array, null);
case SSH_ED448_CERT_V01_AT_OPENSSH_DOT_COM:
+ case SSH_ED448_CERT:
byte[] ed448_pub_array = new byte[buffer.getInt()];
buffer.getByte(ed448_pub_array);
return new KeyPairEd448(instLogger, ed448_pub_array, null);
diff --git a/src/main/java/com/jcraft/jsch/OpenSshCertificateUtil.java b/src/main/java/com/jcraft/jsch/OpenSshCertificateUtil.java
index a25b696d7..78bcef704 100644
--- a/src/main/java/com/jcraft/jsch/OpenSshCertificateUtil.java
+++ b/src/main/java/com/jcraft/jsch/OpenSshCertificateUtil.java
@@ -1,8 +1,14 @@
package com.jcraft.jsch;
+import java.io.IOException;
import java.time.Instant;
+import java.util.Collection;
import java.util.Date;
+import java.util.Map;
+import static com.jcraft.jsch.OpenSshCertificate.SSH2_CERT_TYPE_HOST;
+import static com.jcraft.jsch.OpenSshCertificateAwareIdentityFile.isOpenSshCertificateKeyType;
+import static com.jcraft.jsch.OpenSshCertificateParser.parsePublicKey;
import static java.nio.charset.StandardCharsets.UTF_8;
class OpenSshCertificateUtil {
@@ -64,6 +70,28 @@ static boolean isEmpty(CharSequence cs) {
return cs == null || cs.length() == 0;
}
+
+ /**
+ * Checks if a Collection is empty or null.
+ *
+ * @param c the Collection to check, may be null
+ * @return true if the Collection is null or has 0 elements, false otherwise
+ */
+ static boolean isEmpty(Collection> c) {
+ return c == null || c.isEmpty();
+ }
+
+ /**
+ * Checks if a Map is empty or null.
+ *
+ * @param c the Map to check, may be null
+ * @return true if the Map is null or has 0 elements, false otherwise
+ */
+ static boolean isEmpty(Map, ?> c) {
+ return c == null || c.isEmpty();
+ }
+
+
/**
* Extracts the key type from a certificate file content string. This method assumes the key type
* is the first field (index 0) in the space-delimited string.
@@ -153,7 +181,9 @@ public static String extractKeyType(byte[] s) throws IllegalArgumentException {
* otherwise
*/
static boolean isValidNow(OpenSshCertificate cert) {
+
long now = Instant.now().getEpochSecond();
+
return Long.compareUnsigned(cert.getValidAfter(), now) <= 0
&& Long.compareUnsigned(now, cert.getValidBefore()) < 0;
}
@@ -199,4 +229,78 @@ static String getRawKeyType(String keyType) {
return keyType.substring(0, index);
}
+ /**
+ * Checks if a given byte array represents an OpenSSH host certificate.
+ *
+ * This method parses the provided byte array to determine if it conforms to the structure of an
+ * OpenSSH certificate and, if so, verifies that its type is a host certificate. It performs
+ * checks for null or empty input, validates the key type, and then extracts the certificate type
+ * from the buffer.
+ *
+ * @param instLogger An instance of {@link JSch.InstanceLogger} for logging purposes.
+ * @param bytes The byte array containing the certificate data to be checked.
+ * @return {@code true} if the byte array represents a valid OpenSSH host certificate;
+ * {@code false} otherwise.
+ * @throws JSchException if there is an issue parsing the certificate data, such as malformed
+ * data.
+ */
+ static boolean isOpenSshHostCertificate(JSch.InstanceLogger instLogger, byte[] bytes)
+ throws JSchException {
+ if (bytes == null || bytes.length == 0) {
+ return false;
+ }
+
+ OpenSshCertificateBuffer buffer = new OpenSshCertificateBuffer(bytes);
+
+ String keyType = buffer.getString(UTF_8);
+ if (isEmpty(keyType) || !isOpenSshCertificateKeyType(keyType)) {
+ return false;
+ }
+
+ // discard nonce
+ buffer.getString();
+ // public key
+ parsePublicKey(instLogger, keyType, buffer);
+ // serial
+ buffer.getLong();
+ // type
+ int certificateType = buffer.getInt();
+
+ return certificateType == SSH2_CERT_TYPE_HOST;
+ }
+
+ /**
+ * Reads the entire content of a specified file into a String using UTF-8 encoding. This method
+ * delegates the file reading to a utility function and then converts the resulting byte array
+ * into a String.
+ *
+ * @param filePath The path to the file to be read.
+ * @return A {@code String} containing the entire content of the file.
+ * @throws JSchException If an error related to JSch occurs during file processing (consider if
+ * this exception is truly necessary here, as basic file reading usually only throws
+ * IOException).
+ * @throws IOException If an I/O error occurs while reading the file.
+ * @see Util#fromFile(String)
+ */
+ static String fromFile(String filePath) throws JSchException, IOException {
+ byte[] fileContent = Util.fromFile(filePath);
+ return new String(fileContent, UTF_8);
+ }
+
+ /**
+ * Checks if the given {@code HostKey} represents a Certificate Authority (CA) public key entry. A
+ * host key is considered a CA public key entry if its marker is exactly
+ * {@code "@cert-authority"}.
+ *
+ * @param hostKey The {@link HostKey} object to check.
+ * @return {@code true} if the host key's marker indicates it is a CA public key entry;
+ * {@code false} otherwise, including if the {@code hostKey} is {@code null}.
+ * @see com.jcraft.jsch.HostKey#getMarker()
+ */
+ static boolean isKnownHostCaPublicKeyEntry(HostKey hostKey) {
+ if (hostKey == null) {
+ return false;
+ }
+ return "@cert-authority".equals(hostKey.getMarker());
+ }
}
diff --git a/src/main/java/com/jcraft/jsch/Session.java b/src/main/java/com/jcraft/jsch/Session.java
index 458033f31..9663a6119 100644
--- a/src/main/java/com/jcraft/jsch/Session.java
+++ b/src/main/java/com/jcraft/jsch/Session.java
@@ -928,7 +928,17 @@ private void send_extinfo() throws Exception {
}
}
- private void checkHost(String chost, int port, KeyExchange kex) throws JSchException {
+
+
+ private void checkHost(String chost, int port, KeyExchange kex) throws Exception {
+ if (!kex.isOpenSshServerHostKeyType) {
+ checkHostKey(chost, port, kex);
+ return;
+ }
+ checkHostCertificate(this, kex);
+ }
+
+ private void checkHostKey(String chost, int port, KeyExchange kex) throws JSchException {
String shkc = getConfig("StrictHostKeyChecking");
if (hostKeyAlias != null) {
@@ -1167,6 +1177,8 @@ private Channel getChannelById(int id) {
}
Buffer read(Buffer buf) throws Exception {
+ // determine which encryption and authentication mode is currently active
+ // for the server-to-client (s2c) connection
int j = 0;
boolean isChaCha20 = (s2ccipher != null && s2ccipher.isChaCha20());
boolean isAEAD = (s2ccipher != null && s2ccipher.isAEAD());
@@ -1265,17 +1277,23 @@ Buffer read(Buffer buf) throws Exception {
s2ccipher.update(buf.buffer, 4, j, buf.buffer, 4);
}
} else {
+ // fall back to the older Encrypt-and-MAC mode.
io.getByte(buf.buffer, buf.index, s2ccipher_size);
buf.index += s2ccipher_size;
if (s2ccipher != null) {
s2ccipher.update(buf.buffer, 0, s2ccipher_size, buf.buffer, 0);
}
+
+ // calculating length of the incoming packet
j = ((buf.buffer[0] << 24) & 0xff000000) | ((buf.buffer[1] << 16) & 0x00ff0000)
| ((buf.buffer[2] << 8) & 0x0000ff00) | ((buf.buffer[3]) & 0x000000ff);
// RFC 4253 6.1. Maximum Packet Length
if (j < 5 || j > PACKET_MAX_SIZE) {
start_discard(buf, s2ccipher, s2cmac, 0, PACKET_MAX_SIZE);
}
+
+ // calculates how many more bytes need to be read from the buffer to get the rest of the
+ // encrypted SSH packet
int need = j + 4 - s2ccipher_size;
// if(need<0){
// throw new IOException("invalid data");
diff --git a/src/main/java/com/jcraft/jsch/SignatureWrapper.java b/src/main/java/com/jcraft/jsch/SignatureWrapper.java
new file mode 100644
index 000000000..f570b4455
--- /dev/null
+++ b/src/main/java/com/jcraft/jsch/SignatureWrapper.java
@@ -0,0 +1,189 @@
+package com.jcraft.jsch;
+
+import static java.nio.charset.StandardCharsets.UTF_8;
+
+/**
+ * A factory and wrapper class for creating and managing digital signature instances.
+ *
+ * This class abstracts the creation of specific signature algorithm implementations (like RSA, DSA,
+ * ECDSA, EdDSA) by dynamically loading them based on an algorithm name. It also provides a generic
+ * interface for setting the public key and performing signature operations (init, update, verify,
+ * sign) by delegating calls to the - * underlying signature instance.
+ *
+ */
+public class SignatureWrapper implements Signature {
+
+ private final Signature signature;
+
+ private final PubKeySetter publicKeySetter;
+
+ private final PubKeyParameterValidator pubKeyParameterValidator;
+
+ /**
+ * Constructs a {@code SignatureWrapper} by parsing the algorithm name from a signature data blob.
+ *
+ * @param dataSignature a byte array containing the OpenSSH certificate buffer, from which the
+ * signature algorithm name is extracted.
+ * @throws JSchException if the algorithm name cannot be parsed or if the underlying signature
+ * instance cannot be created.
+ */
+ public SignatureWrapper(byte[] dataSignature) throws JSchException {
+ this(new OpenSshCertificateBuffer(dataSignature).getString(UTF_8));
+ }
+
+ /**
+ * Constructs a {@code SignatureWrapper} for the specified signature algorithm.
+ *
+ * This constructor uses reflection to find and instantiate the appropriate {@link Signature}
+ * implementation based on the algorithm name retrieved from JSch's configuration. It also sets up
+ * handlers for public key validation and setting based on the algorithm type.
+ *
+ *
+ * @param algorithm the name of the signature algorithm (e.g., "ssh-rsa", "ssh-dss").
+ * @throws JSchException if the specified algorithm is not recognized, the implementation class
+ * cannot be found, or an instance cannot be created.
+ */
+ public SignatureWrapper(String algorithm) throws JSchException {
+ try {
+ this.signature = Class.forName(JSch.getConfig(algorithm)).asSubclass(Signature.class)
+ .getDeclaredConstructor().newInstance();
+ } catch (Exception e) {
+ throw new JSchException("Failed to instantiate signature for algorithm '" + algorithm + "'",
+ e);
+ }
+
+ if (signature instanceof SignatureRSA) {
+ pubKeyParameterValidator = (byte[][] args) -> generateValidator("RSA", 2);
+ publicKeySetter =
+ (byte[][] args) -> ((SignatureRSA) this.signature).setPubKey(args[0], args[1]);
+ } else if (signature instanceof SignatureDSA) {
+ pubKeyParameterValidator = (byte[][] args) -> generateValidator("DSA", 4);
+ publicKeySetter = (byte[][] args) -> ((SignatureDSA) this.signature).setPubKey(args[0],
+ args[1], args[2], args[3]);
+ } else if (signature instanceof SignatureECDSA) {
+ pubKeyParameterValidator = (byte[][] args) -> generateValidator("ECDSA", 2);
+ publicKeySetter =
+ (byte[][] args) -> ((SignatureECDSA) this.signature).setPubKey(args[0], args[1]);
+ } else if (signature instanceof SignatureEdDSA) {
+ pubKeyParameterValidator = (byte[][] args) -> generateValidator("EdDSA", 1);
+ publicKeySetter = (byte[][] args) -> ((SignatureEdDSA) this.signature).setPubKey(args[0]);
+ } else {
+ throw new JSchException("Unrecognized signature algorithm: " + algorithm);
+ }
+ }
+
+ /**
+ * Generates a validator for the public key parameters.
+ *
+ * @param algorithm the algorithm name, used for error messages.
+ * @param expectedParametersNo the exact number of byte arrays expected for the public key.
+ * @return a {@link PubKeyParameterValidator} instance.
+ * @throws JSchException if the number of provided parameters does not match the expected count.
+ */
+ private static PubKeyParameterValidator generateValidator(String algorithm,
+ int expectedParametersNo) throws JSchException {
+ return (byte[][] params) -> {
+ if (params.length != expectedParametersNo) {
+ throw new JSchException("wrong number of arguments:" + algorithm + " signatures expects "
+ + expectedParametersNo + " parameters, found " + params.length);
+ }
+ };
+ }
+
+
+ /**
+ * Initializes the underlying signature instance for signing or verification. This method
+ * delegates the call to the wrapped signature object.
+ *
+ * @throws Exception if an error occurs during initialization.
+ */
+ @Override
+ public void init() throws Exception {
+ signature.init();
+ }
+
+ /**
+ * Updates the data to be signed or verified with the given byte array. This method delegates the
+ * call to the wrapped signature object.
+ *
+ * @param H the byte array to update the signature data with.
+ * @throws Exception if an error occurs during the update.
+ */
+ @Override
+ public void update(byte[] H) throws Exception {
+ signature.update(H);
+ }
+
+ /**
+ * Verifies the provided signature. This method delegates the call to the wrapped signature
+ * object.
+ *
+ * @param sig the signature bytes to be verified.
+ * @return {@code true} if the signature is valid, {@code false} otherwise.
+ * @throws Exception if an error occurs during verification.
+ */
+ @Override
+ public boolean verify(byte[] sig) throws Exception {
+ return signature.verify(sig);
+ }
+
+ /**
+ * Generates the digital signature of all the data updated so far. This method delegates the call
+ * to the wrapped signature object.
+ *
+ * @return the byte array representing the digital signature.
+ * @throws Exception if an error occurs during the signing process.
+ */
+ @Override
+ public byte[] sign() throws Exception {
+ return signature.sign();
+ }
+
+
+ /**
+ * Sets the public key required for signature verification.
+ *
+ * This method first validates that the correct number of key parameters are provided for the
+ * specific algorithm and then passes them to the underlying signature instance.
+ *
+ *
+ * @param args a variable number of byte arrays representing the public key components.
+ * @throws Exception if the key parameters are invalid or if an error occurs while setting the
+ * public key on the underlying signature instance.
+ */
+ public void setPubKey(byte[]... args) throws Exception {
+ pubKeyParameterValidator.validatePublicKeyParameter(args);
+ publicKeySetter.setPubKey(args);
+ }
+
+
+ /**
+ * A functional interface for setting a public key on a {@link Signature} instance.
+ */
+ @FunctionalInterface
+ private interface PubKeySetter {
+
+ /**
+ * Sets the public key components on the signature instance.
+ *
+ * @param keyParams the components of the public key.
+ * @throws Exception if an error occurs during the operation.
+ */
+ void setPubKey(byte[]... keyParams) throws Exception;
+ }
+
+ /**
+ * A functional interface for validating the public key parameters.
+ */
+ @FunctionalInterface
+ private interface PubKeyParameterValidator {
+ /**
+ * Validates the provided public key components.
+ *
+ * @param keyParams the components of the public key to validate.
+ * @throws Exception if the parameters are invalid.
+ */
+ void validatePublicKeyParameter(byte[]... keyParams) throws Exception;
+ }
+
+}
diff --git a/src/test/java/com/jcraft/jsch/HostCertificateIT.java b/src/test/java/com/jcraft/jsch/HostCertificateIT.java
new file mode 100644
index 000000000..0c2ab2869
--- /dev/null
+++ b/src/test/java/com/jcraft/jsch/HostCertificateIT.java
@@ -0,0 +1,216 @@
+package com.jcraft.jsch;
+
+import com.github.valfirst.slf4jtest.LoggingEvent;
+import com.github.valfirst.slf4jtest.TestLogger;
+import com.github.valfirst.slf4jtest.TestLoggerFactory;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.MethodSource;
+import org.testcontainers.containers.GenericContainer;
+import org.testcontainers.containers.wait.strategy.Wait;
+import org.testcontainers.images.builder.ImageFromDockerfile;
+import org.testcontainers.junit.jupiter.Container;
+import org.testcontainers.junit.jupiter.Testcontainers;
+
+import java.util.Arrays;
+
+import static com.jcraft.jsch.ResourceUtil.getResourceFile;
+import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * Integration tests for SSH host certificate validation.
+ *
+ * These tests leverage Testcontainers to spin up a dedicated SSH server in a Docker container,
+ * configured with specific host keys and certificates. The primary goal is to verify JSch's ability
+ * to validate server host keys signed by a Certificate Authority (CA) against a {@code known_hosts}
+ * file.
+ */
+
+@Testcontainers
+public class HostCertificateIT {
+
+ /** Connection timeout in milliseconds. */
+ private static final int TIMEOUT = 5000;
+ /** Base resource folder for certificates and keys used in the tests. */
+ private static final String CERTIFICATES_BASE_FOLDER = "certificates/host";
+ /** Test logger for capturing JSch internal logs for debugging purposes. */
+ private static final TestLogger jschLogger = TestLoggerFactory.getTestLogger(JSch.class);
+ /** Test logger for capturing SSH server logs (via a placeholder class). */
+ private static final TestLogger sshdLogger =
+ TestLoggerFactory.getTestLogger(UserCertAuthIT.class);
+
+ /**
+ * Defines and configures the SSH server Docker container using Testcontainers. The container is
+ * built from a custom Dockerfile that:
+ *
+ * - Starts from a lightweight Alpine Linux image.
+ * - Installs an OpenSSH server.
+ * - Creates a test user ('luigi').
+ * - Copies all necessary configuration, keys, and certificates from the test resources.
+ * - Starts the SSH server via a custom entrypoint script.
+ *
+ */
+ @Container
+ private GenericContainer> sshdContainer =
+ new GenericContainer<>(new ImageFromDockerfile("jsch_host_key_test", false)
+ .withDockerfileFromBuilder(builder -> builder.from("alpine:3.16")
+ .run("apk add --update openssh openssh-server bash && " + "rm /var/cache/apk/*")
+ .run("adduser -D luigi") // Add a user
+ .run("echo 'luigi:passwordLuigi' | chpasswd") // Unlock the user
+ .run("mkdir -p /home/luigi/.ssh").copy("sshd_config", "/etc/ssh/sshd_config")
+ .copy("authorized_keys", "/home/luigi/.ssh/authorized_keys")
+ .copy("entrypoint.sh", "/entrypoint.sh")
+ .copy("ssh_host_rsa_key", "/etc/ssh/ssh_host_rsa_key")
+ .copy("ssh_host_rsa_key-cert.pub", "/etc/ssh/ssh_host_rsa_key-cert.pub")
+ .copy("ssh_host_ecdsa_key", "/etc/ssh/ssh_host_ecdsa_key")
+ .copy("ssh_host_ecdsa_key-cert.pub", "/etc/ssh/ssh_host_ecdsa_key-cert.pub")
+ .copy("ssh_host_ed25519_key", "/etc/ssh/ssh_host_ed25519_key")
+ .copy("ssh_host_ed25519_key-cert.pub", "/etc/ssh/ssh_host_ed25519_key-cert.pub")
+ .entryPoint("/entrypoint.sh").build())
+ .withFileFromClasspath("sshd_config", CERTIFICATES_BASE_FOLDER + "/sshd_config")
+ .withFileFromClasspath("authorized_keys",
+ CERTIFICATES_BASE_FOLDER + "/user_keys/id_ecdsa_nistp521.pub")
+ .withFileFromClasspath("entrypoint.sh", CERTIFICATES_BASE_FOLDER + "/entrypoint.sh")
+ .withFileFromClasspath("ssh_host_rsa_key", CERTIFICATES_BASE_FOLDER + "/ssh_host_rsa_key")
+ .withFileFromClasspath("ssh_host_rsa_key-cert.pub",
+ CERTIFICATES_BASE_FOLDER + "/ssh_host_rsa_key-cert.pub")
+ .withFileFromClasspath("ssh_host_ecdsa_key",
+ CERTIFICATES_BASE_FOLDER + "/ssh_host_ecdsa_key")
+ .withFileFromClasspath("ssh_host_ecdsa_key-cert.pub",
+ CERTIFICATES_BASE_FOLDER + "/ssh_host_ecdsa_key-cert.pub")
+ .withFileFromClasspath("ssh_host_ed25519_key",
+ CERTIFICATES_BASE_FOLDER + "/ssh_host_ed25519_key")
+ .withFileFromClasspath("ssh_host_ed25519_key-cert.pub",
+ CERTIFICATES_BASE_FOLDER + "/ssh_host_ed25519_key-cert.pub"))
+ .withExposedPorts(22)
+ .waitingFor(Wait.forLogMessage(".*Server listening on :: port 22.*", 1));
+
+
+ /**
+ * Provides a stream of server host key algorithms to be used in parameterized tests. Each string
+ * corresponds to the {@code server_host_key} configuration option in JSch.
+ *
+ * @return An iterable of host key algorithm strings for test parameterization.
+ */
+ public static Iterable extends String> privateKeyParams() {
+ return Arrays.asList("ssh-ed25519-cert-v01@openssh.com",
+ "ecdsa-sha2-nistp256-cert-v01@openssh.com,ecdsa-sha2-nistp384-cert-v01@openssh.com,ecdsa-sha2-nistp521-cert-v01@openssh.com",
+ "ssh-rsa-cert-v01@openssh.com,rsa-sha2-512-cert-v01@openssh.com,rsa-sha2-256-cert-v01@openssh.com");
+ }
+
+ /**
+ * Tests the successful connection scenario where the server's host certificate is signed by a CA
+ * that is trusted in the client's {@code known_hosts} file. This test is parameterized to run
+ * against different host key algorithms.
+ *
+ * @param algorithm The server host key algorithm to test, provided by
+ * {@link #privateKeyParams()}.
+ * @throws Exception if any error occurs during the test.
+ */
+ @MethodSource("privateKeyParams")
+ @ParameterizedTest(name = "hostkey algorithm: {0}")
+ public void hostKeyTestHappyPath(String algorithm) throws Exception {
+ JSch ssh = new JSch();
+ ssh.addIdentity(
+ getResourceFile(this.getClass(), CERTIFICATES_BASE_FOLDER + "/user_keys/id_ecdsa_nistp521"),
+ getResourceFile(this.getClass(),
+ CERTIFICATES_BASE_FOLDER + "/user_keys/id_ecdsa_nistp521.pub"),
+ null);
+
+ ssh.setKnownHosts(getResourceFile(this.getClass(), "certificates/known_hosts"));
+ Session session =
+ ssh.getSession("luigi", sshdContainer.getHost(), sshdContainer.getFirstMappedPort());
+ session.setConfig("enable_auth_none", "no");
+ session.setConfig("StrictHostKeyChecking", "yes");
+ session.setConfig("PreferredAuthentications", "publickey");
+ session.setConfig("server_host_key", algorithm);
+ assertDoesNotThrow(() -> {
+ connectSftp(session);
+ });
+ }
+
+ /**
+ * Tests the failure scenario where the server's host certificate cannot be trusted. This test
+ * verifies that a {@link JSchHostKeyException} is thrown, as expected when
+ * {@code StrictHostKeyChecking} is enabled and the host key/certificate does not match any entry
+ * in the {@code known_hosts} file.
+ *
+ * @param algorithm The server host key algorithm to test, provided by
+ * {@link #privateKeyParams()}.
+ * @throws Exception if any error occurs during the test setup.
+ */
+ @MethodSource("privateKeyParams")
+ @ParameterizedTest(name = "hostkey algorithm: {0}")
+ public void hostKeyTestNotTrustedCA(String algorithm) throws Exception {
+ JSch ssh = new JSch();
+
+ ssh.addIdentity(
+ getResourceFile(this.getClass(), CERTIFICATES_BASE_FOLDER + "/user_keys/id_ecdsa_nistp521"),
+ getResourceFile(this.getClass(),
+ CERTIFICATES_BASE_FOLDER + "/user_keys/id_ecdsa_nistp521.pub"),
+ null);
+
+ Session session = setup(ssh, algorithm);
+ assertThrows(JSchHostKeyException.class, () -> {
+ connectSftp(session);
+ });
+ }
+
+
+ /**
+ * Helper method to create and configure a JSch {@link Session} with common settings for the
+ * tests.
+ *
+ * @param ssh The JSch instance.
+ * @param algorithm The server host key algorithm to prefer.
+ * @return A configured {@link Session} object.
+ * @throws JSchException if there is an error creating the session.
+ */
+ private Session setup(JSch ssh, String algorithm) throws JSchException {
+ Session session =
+ ssh.getSession("luigi", sshdContainer.getHost(), sshdContainer.getFirstMappedPort());
+ session.setConfig("enable_auth_none", "no");
+ session.setConfig("StrictHostKeyChecking", "yes");
+ session.setConfig("PreferredAuthentications", "publickey");
+ session.setConfig("server_host_key", algorithm);
+ return session;
+ }
+
+ /**
+ * Establishes a session connection, opens an SFTP channel to verify connectivity, and then
+ * cleanly disconnects. If any exception occurs, it prints diagnostic information before
+ * re-throwing the exception.
+ *
+ * @param session The session to connect with.
+ * @throws JSchException if a JSch-specific error occurs.
+ */
+ private void connectSftp(Session session) throws JSchException {
+ try {
+ session.setTimeout(TIMEOUT);
+ session.connect();
+ ChannelSftp sftp = (ChannelSftp) session.openChannel("sftp");
+ sftp.connect(TIMEOUT);
+ assertTrue(sftp.isConnected());
+ sftp.disconnect();
+ session.disconnect();
+ } catch (Exception e) {
+ printInfo();
+ throw e;
+ }
+ }
+
+ /**
+ * A utility method for debugging. Prints all captured log events from both the JSch client and
+ * the mock SSH server to the console.
+ */
+ private void printInfo() {
+ jschLogger.getAllLoggingEvents().stream().map(LoggingEvent::getFormattedMessage)
+ .forEach(System.out::println);
+ sshdLogger.getAllLoggingEvents().stream().map(LoggingEvent::getFormattedMessage)
+ .forEach(System.out::println);
+ System.out.println("");
+ System.out.println("");
+ System.out.println("");
+ }
+}
diff --git a/src/test/java/com/jcraft/jsch/KeyExchangeTest.java b/src/test/java/com/jcraft/jsch/KeyExchangeTest.java
index c6fd2dd37..026c39bac 100644
--- a/src/test/java/com/jcraft/jsch/KeyExchangeTest.java
+++ b/src/test/java/com/jcraft/jsch/KeyExchangeTest.java
@@ -247,7 +247,7 @@ public void init(Session session, byte[] V_S, byte[] V_C, byte[] I_S, byte[] I_C
}
@Override
- public boolean next(Buffer buf) throws Exception {
+ public boolean doNext(Buffer buf, int sshMessageType) throws Exception {
throw new UnsupportedOperationException("Not supported");
}
diff --git a/src/test/java/com/jcraft/jsch/UserCertAuthIT.java b/src/test/java/com/jcraft/jsch/UserCertAuthIT.java
index 04a6841e4..6bdf018ce 100644
--- a/src/test/java/com/jcraft/jsch/UserCertAuthIT.java
+++ b/src/test/java/com/jcraft/jsch/UserCertAuthIT.java
@@ -9,6 +9,7 @@
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.testcontainers.containers.GenericContainer;
+import org.testcontainers.containers.wait.strategy.Wait;
import org.testcontainers.images.builder.ImageFromDockerfile;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
@@ -20,57 +21,128 @@
import java.util.List;
import java.util.Locale;
+import static com.jcraft.jsch.ResourceUtil.getResourceFile;
import static java.nio.charset.StandardCharsets.UTF_8;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import static org.junit.jupiter.api.Assertions.assertTrue;
+/**
+ * Integrated Test (IT) class to verify the JSch public key authentication mechanism using **OpenSSH
+ * user certificates** against a Testcontainers-managed SSHD (SSH daemon) instance.
+ *
+ * This test suite ensures that **JSch can successfully establish an SFTP connection** when
+ * configured with various types of certified user keys (e.g., RSA, ECDSA, Ed25519). The container
+ * is configured to trust the certificate authority (CA) key that signed the user certificates being
+ * tested.
+ */
+
@Testcontainers
public class UserCertAuthIT {
+ /**
+ * Standard SLF4J logger for this test class.
+ */
private static final Logger logger = LoggerFactory.getLogger(UserCertAuthIT.class);
- private static final int timeout = 2000;
+ /**
+ * Timeout value (in milliseconds) for session and channel connections.
+ */
+ private static final int timeout = 5000;
+ /**
+ * Utility for generating SHA-256 digests.
+ */
private static final DigestUtils sha256sum = new DigestUtils(DigestUtils.getSha256Digest());
+ /**
+ * Test logger for capturing JSch internal logging output.
+ */
private static final TestLogger jschLogger = TestLoggerFactory.getTestLogger(JSch.class);
+ /**
+ * Test logger for capturing the logging output of this test class (the SSHD setup).
+ */
private static final TestLogger sshdLogger =
TestLoggerFactory.getTestLogger(UserCertAuthIT.class);
+
+ /**
+ * The Testcontainers instance for the SSHD server.
+ *
+ * The container is built from a Dockerfile and configured with:
+ *
+ * - A host RSA key (`ssh_host_rsa_key`).
+ * - A host certificate (`ssh_host_rsa_key-cert.pub`).
+ * - A Certificate Authority (CA) public key (`ca_jsch_key.pub`) to validate user
+ * certificates.
+ * - An SSH configuration file (`sshd_config`).
+ *
+ */
@Container
public GenericContainer> sshd = new GenericContainer<>(new ImageFromDockerfile()
.withFileFromClasspath("ssh_host_rsa_key", "certificates/docker/ssh_host_rsa_key")
- .withFileFromClasspath("ssh_host_rsa_key.pub", "certificates/docker/ssh_host_rsa_key.pub")
+ // .withFileFromClasspath("ssh_host_rsa_key.pub", "certificates/docker/ssh_host_rsa_key.pub")
.withFileFromClasspath("ssh_host_rsa_key-cert.pub",
"certificates/docker/ssh_host_rsa_key-cert.pub")
.withFileFromClasspath("ca_jsch_key.pub", "certificates/ca/ca_jsch_key.pub")
.withFileFromClasspath("sshd_config", "certificates/docker/sshd_config")
- .withFileFromClasspath("Dockerfile", "certificates/docker/Dockerfile")).withExposedPorts(22);
-
-
+ .withFileFromClasspath("Dockerfile", "certificates/docker/Dockerfile")).withExposedPorts(22)
+ .waitingFor(Wait.forLogMessage(".*Server listening on :: port 22.*", 1));
+
+
+ /**
+ * Provides the list of private key parameters used for the parameterized test.
+ *
+ * The keys represent various supported algorithms for user certificates.
+ *
+ * @return An {@code Iterable} of strings, each representing a private key file path prefix (e.g.,
+ * "ecdsa_p256/root_ecdsa_sha2_nistp256_key").
+ */
public static Iterable extends String> privateKeyParams() {
return Arrays.asList(
- // disable dss because dsa algotrithm is deprecated and removed by openssh server
+ // disable dss because dsa algorithm is deprecated and removed by openssh server
/* "dss/root_dsa_key", */
"ecdsa_p256/root_ecdsa_sha2_nistp256_key", "ecdsa_p384/root_ecdsa-sha2-nistp384_key",
"ecdsa_p521/root_ecdsa_sha2_nistp521_key", "ed25519/root_ed25519_key", "rsa/root_rsa_key");
}
-
+ /**
+ * Tests JSch's ability to perform public key authentication using OpenSSH user certificates for
+ * various key types.
+ *
+ * The test adds the private key and its corresponding certificate (`-cert.pub`) to JSch and
+ * attempts an SFTP connection to the Testcontainers-managed SSHD.
+ *
+ * @param privateKey The path prefix to the private key and certificate files (from the test
+ * resource directory).
+ * @throws Exception if any error occurs during key reading, session setup, or connection.
+ */
@MethodSource("privateKeyParams")
@ParameterizedTest(name = "key: {0}, cert: {0}-cert.pub")
public void opensshCertificateParserTest(String privateKey) throws Exception {
- HostKey hostKey = readHostKey(getResourceFile("certificates/docker/ssh_host_rsa_key.pub"));
+ HostKey hostKey =
+ readHostKey(getResourceFile(this.getClass(), "certificates/docker/ssh_host_rsa_key.pub"));
JSch ssh = new JSch();
- ssh.addIdentity(getResourceFile("certificates/" + privateKey),
- getResourceFile("certificates/" + privateKey + "-cert.pub"), null);
+ ssh.addIdentity(getResourceFile(this.getClass(), "certificates/" + privateKey),
+ getResourceFile(this.getClass(), "certificates/" + privateKey + "-cert.pub"), null);
ssh.getHostKeyRepository().add(hostKey, null);
Session session = ssh.getSession("root", sshd.getHost(), sshd.getFirstMappedPort());
session.setConfig("enable_auth_none", "yes");
session.setConfig("StrictHostKeyChecking", "no");
session.setConfig("PreferredAuthentications", "publickey");
+ session.setConfig("server_host_key",
+ "ssh-ed25519,ecdsa-sha2-nistp256,ecdsa-sha2-nistp384,ecdsa-sha2-nistp521,rsa-sha2-512,rsa-sha2-256");
doSftp(session);
}
+ /**
+ * Reads a public key file and constructs a {@link HostKey} object.
+ *
+ * This method simulates how a known host entry might be created, but uses hardcoded placeholder
+ * values for hostname and port in the constructed {@code HostKey}.
+ *
+ * @param fileName The absolute path to the public key file (e.g., {@code ssh_host_rsa_key.pub}).
+ * @return A {@link HostKey} instance representing the key.
+ * @throws Exception if the file cannot be read or the key content is malformed.
+ */
private HostKey readHostKey(String fileName) throws Exception {
List lines = Files.readAllLines(Paths.get(fileName), UTF_8);
String[] split = lines.get(0).split("\\s+");
@@ -78,7 +150,15 @@ private HostKey readHostKey(String fileName) throws Exception {
return new HostKey(hostname, Base64.getDecoder().decode(split[1]));
}
-
+ /**
+ * Connects the provided {@link Session} and attempts to perform a simple SFTP operation.
+ *
+ * This method wraps the connection and SFTP channel creation in an {@code assertDoesNotThrow} to
+ * ensure the entire process, including authentication, completes successfully.
+ *
+ * @param session The configured JSch session to connect.
+ * @throws Exception if connection or SFTP channel setup fails.
+ */
private void doSftp(Session session) throws Exception {
assertDoesNotThrow(() -> {
try {
@@ -96,6 +176,10 @@ private void doSftp(Session session) throws Exception {
});
}
+ /**
+ * Prints all captured logging events from the JSch and SSHD test loggers to the standard output
+ * for debugging purposes, primarily on test failure.
+ */
private void printInfo() {
jschLogger.getAllLoggingEvents().stream().map(LoggingEvent::getFormattedMessage)
.forEach(System.out::println);
@@ -105,8 +189,4 @@ private void printInfo() {
System.out.println("");
System.out.println("");
}
-
- private String getResourceFile(String fileName) {
- return ResourceUtil.getResourceFile(getClass(), fileName);
- }
}
diff --git a/src/test/resources/certificates/docker/Dockerfile b/src/test/resources/certificates/docker/Dockerfile
index 24d9851e0..d0b521569 100644
--- a/src/test/resources/certificates/docker/Dockerfile
+++ b/src/test/resources/certificates/docker/Dockerfile
@@ -6,7 +6,7 @@ RUN apk update && \
mkdir /root/.ssh && \
chmod 700 /root/.ssh
COPY ssh_host_rsa_key /etc/ssh/
-COPY ssh_host_rsa_key.pub /etc/ssh/
+#COPY ssh_host_rsa_key.pub /etc/ssh/
COPY ssh_host_rsa_key-cert.pub /etc/ssh/ssh_host_rsa_key-cert.pub
COPY sshd_config /etc/ssh/
COPY ca_jsch_key.pub /etc/ssh/ca_jsch_key.pub
@@ -14,4 +14,4 @@ COPY ca_jsch_key.pub /etc/ssh/ca_jsch_key.pub
RUN chmod 600 /etc/ssh/ssh_*_key /root/.ssh -R
RUN passwd -u root
EXPOSE 22
-ENTRYPOINT ["/usr/sbin/sshd", "-D", "-e"]
\ No newline at end of file
+ENTRYPOINT ["/usr/sbin/sshd", "-D", "-e"]
diff --git a/src/test/resources/certificates/host/entrypoint.sh b/src/test/resources/certificates/host/entrypoint.sh
new file mode 100755
index 000000000..6c9047734
--- /dev/null
+++ b/src/test/resources/certificates/host/entrypoint.sh
@@ -0,0 +1,5 @@
+#!/bin/bash
+chown -R luigi /home/luigi
+chmod 0600 /home/luigi/.ssh/*
+chmod 600 /etc/ssh/ssh_*_key -R
+/usr/sbin/sshd -D -ddd -e
diff --git a/src/test/resources/certificates/host/ssh_host_dsa_key b/src/test/resources/certificates/host/ssh_host_dsa_key
new file mode 100644
index 000000000..10200152b
--- /dev/null
+++ b/src/test/resources/certificates/host/ssh_host_dsa_key
@@ -0,0 +1,21 @@
+-----BEGIN OPENSSH PRIVATE KEY-----
+b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAABsQAAAAdzc2gtZH
+NzAAAAgQDCNtFJIHt4LMoBQTFOzpdEed4A358dCpM1Q35g6lUZnn8UBE1DndopqUOiTr8Y
+iM8rqfG11rHYEWN4b3vZ1ogogtwpTT4WQ+Ac0CSKsAuFgg8R6bfXk1oc/Ja9YMPt4tVL7i
+GkMoTG1WJdBsuiOzcANZD2whd/luTpEwwfpM5HaQAAABUAr2+0r9JzvK2MXhWqbaGwCqjT
+GIcAAACAPXS+XP2AYd6q3TfPz4VVCgE78IK2uZPn6jNN9B70Pr6z/h2Qm0BZ9UEtLXOvhl
+Ek+gxpkvBuWi6Cdnhm5ROrM7EQNYw6Nv1xNBJr1o3oLITyoJp/TaecLc4/kAlqOX4teHmY
+z5g9x8THu0gN6ImNandZIHeC1qfChK7iPlpHG5MAAACAbApTLq4yn89qacgTlfG1OGOkzf
+xiQ5Fu23vZq9+PJ9SyZ/WkU1wWp+vlxrnSTdYLMFs+3E03CT+TwrnvOhRjL7dMJqMf/3Oh
+0p02jVzGQ8Xf0ob7Auz+TY4sUP6epSnCiv/12fGFQpuI6cBBlnBjLt/z6EbH6TM1ELxlJ9
+ZfFtUAAAHox+BxpcfgcaUAAAAHc3NoLWRzcwAAAIEAwjbRSSB7eCzKAUExTs6XRHneAN+f
+HQqTNUN+YOpVGZ5/FARNQ53aKalDok6/GIjPK6nxtdax2BFjeG972daIKILcKU0+FkPgHN
+AkirALhYIPEem315NaHPyWvWDD7eLVS+4hpDKExtViXQbLojs3ADWQ9sIXf5bk6RMMH6TO
+R2kAAAAVAK9vtK/Sc7ytjF4Vqm2hsAqo0xiHAAAAgD10vlz9gGHeqt03z8+FVQoBO/CCtr
+mT5+ozTfQe9D6+s/4dkJtAWfVBLS1zr4ZRJPoMaZLwblougnZ4ZuUTqzOxEDWMOjb9cTQS
+a9aN6CyE8qCaf02nnC3OP5AJajl+LXh5mM+YPcfEx7tIDeiJjWp3WSB3gtanwoSu4j5aRx
+uTAAAAgGwKUy6uMp/PamnIE5XxtThjpM38YkORbtt72avfjyfUsmf1pFNcFqfr5ca50k3W
+CzBbPtxNNwk/k8K57zoUYy+3TCajH/9zodKdNo1cxkPF39KG+wLs/k2OLFD+nqUpwor/9d
+nxhUKbiOnAQZZwYy7f8+hGx+kzNRC8ZSfWXxbVAAAAFQCbvkGgAc//pZvVfQ93SF0FMAvq
+hwAAABFyb290QDgxMmQ1ZmNjMmJlMgE=
+-----END OPENSSH PRIVATE KEY-----
diff --git a/src/test/resources/certificates/host/ssh_host_dsa_key.pub b/src/test/resources/certificates/host/ssh_host_dsa_key.pub
new file mode 100644
index 000000000..8180d52d8
--- /dev/null
+++ b/src/test/resources/certificates/host/ssh_host_dsa_key.pub
@@ -0,0 +1 @@
+ssh-dss AAAAB3NzaC1kc3MAAACBAMI20Ukge3gsygFBMU7Ol0R53gDfnx0KkzVDfmDqVRmefxQETUOd2impQ6JOvxiIzyup8bXWsdgRY3hve9nWiCiC3ClNPhZD4BzQJIqwC4WCDxHpt9eTWhz8lr1gw+3i1UvuIaQyhMbVYl0Gy6I7NwA1kPbCF3+W5OkTDB+kzkdpAAAAFQCvb7Sv0nO8rYxeFaptobAKqNMYhwAAAIA9dL5c/YBh3qrdN8/PhVUKATvwgra5k+fqM030HvQ+vrP+HZCbQFn1QS0tc6+GUST6DGmS8G5aLoJ2eGblE6szsRA1jDo2/XE0EmvWjegshPKgmn9Np5wtzj+QCWo5fi14eZjPmD3HxMe7SA3oiY1qd1kgd4LWp8KEruI+WkcbkwAAAIBsClMurjKfz2ppyBOV8bU4Y6TN/GJDkW7be9mr348n1LJn9aRTXBan6+XGudJN1gswWz7cTTcJP5PCue86FGMvt0wmox//c6HSnTaNXMZDxd/ShvsC7P5NjixQ/p6lKcKK//XZ8YVCm4jpwEGWcGMu3/PoRsfpMzUQvGUn1l8W1Q== root@812d5fcc2be2
diff --git a/src/test/resources/certificates/host/ssh_host_ecdsa_key b/src/test/resources/certificates/host/ssh_host_ecdsa_key
new file mode 100644
index 000000000..af17a4bcf
--- /dev/null
+++ b/src/test/resources/certificates/host/ssh_host_ecdsa_key
@@ -0,0 +1,9 @@
+-----BEGIN OPENSSH PRIVATE KEY-----
+b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAaAAAABNlY2RzYS
+1zaGEyLW5pc3RwMjU2AAAACG5pc3RwMjU2AAAAQQQfpw96c0Uhv6F1R7U6yq8fh18hfpAH
+TstDFdjYOzucFcFgOW8jDSJqmrDuFuDYf40DD2/IC0M6LfIVVDNyAMUfAAAAsBMBRnwTAU
+Z8AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBB+nD3pzRSG/oXVH
+tTrKrx+HXyF+kAdOy0MV2Ng7O5wVwWA5byMNImqasO4W4Nh/jQMPb8gLQzot8hVUM3IAxR
+8AAAAhAOa/ybhNwy5ARRH33b2iiGCvH1nJs2LVFpJ+/hsKZggMAAAAEXJvb3RAODEyZDVm
+Y2MyYmUyAQIDBAUG
+-----END OPENSSH PRIVATE KEY-----
diff --git a/src/test/resources/certificates/host/ssh_host_ecdsa_key-cert.pub b/src/test/resources/certificates/host/ssh_host_ecdsa_key-cert.pub
new file mode 100644
index 000000000..0a69c800e
--- /dev/null
+++ b/src/test/resources/certificates/host/ssh_host_ecdsa_key-cert.pub
@@ -0,0 +1 @@
+ecdsa-sha2-nistp256-cert-v01@openssh.com AAAAKGVjZHNhLXNoYTItbmlzdHAyNTYtY2VydC12MDFAb3BlbnNzaC5jb20AAAAgv6PZKc8OzM3kdo9Mhn1Zj20k/ZHbaCAgs/NExJxLzjgAAAAIbmlzdHAyNTYAAABBBB+nD3pzRSG/oXVHtTrKrx+HXyF+kAdOy0MV2Ng7O5wVwWA5byMNImqasO4W4Nh/jQMPb8gLQzot8hVUM3IAxR8AAAAAAAAAAAAAAAIAAAAKaG9zdF9lY2RzYQAAAA0AAAAJbG9jYWxob3N0AAAAAAAAAAD//////////wAAAAAAAAAAAAAAAAAAADMAAAALc3NoLWVkMjU1MTkAAAAgpPpGR2s9KWbGPhmLM1NKBIrR67s1T0uBLuElIFw+wXAAAABTAAAAC3NzaC1lZDI1NTE5AAAAQEIxgKXLS+HosaCvPlrUsym3K9Oue51qN0qAnKL7v8PsXVXf0u7nEArqLsvcFoODUgemj2nB/grAc2Q0uGznfAU= root@812d5fcc2be2
diff --git a/src/test/resources/certificates/host/ssh_host_ecdsa_key.pub b/src/test/resources/certificates/host/ssh_host_ecdsa_key.pub
new file mode 100644
index 000000000..35359fad2
--- /dev/null
+++ b/src/test/resources/certificates/host/ssh_host_ecdsa_key.pub
@@ -0,0 +1 @@
+ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBB+nD3pzRSG/oXVHtTrKrx+HXyF+kAdOy0MV2Ng7O5wVwWA5byMNImqasO4W4Nh/jQMPb8gLQzot8hVUM3IAxR8= root@812d5fcc2be2
diff --git a/src/test/resources/certificates/host/ssh_host_ed25519_key b/src/test/resources/certificates/host/ssh_host_ed25519_key
new file mode 100644
index 000000000..307f478ba
--- /dev/null
+++ b/src/test/resources/certificates/host/ssh_host_ed25519_key
@@ -0,0 +1,7 @@
+-----BEGIN OPENSSH PRIVATE KEY-----
+b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW
+QyNTUxOQAAACC/A+Qpq1umbEDxIobpJiFIUpJOaK0Bd44A+UoXpJEvbwAAAJh7bpi6e26Y
+ugAAAAtzc2gtZWQyNTUxOQAAACC/A+Qpq1umbEDxIobpJiFIUpJOaK0Bd44A+UoXpJEvbw
+AAAEAnZ+51LM9x7MNO6vkieJDmDSBDFJgmbpRz/fHQX1HmKb8D5CmrW6ZsQPEihukmIUhS
+kk5orQF3jgD5ShekkS9vAAAAEXJvb3RAODEyZDVmY2MyYmUyAQIDBA==
+-----END OPENSSH PRIVATE KEY-----
diff --git a/src/test/resources/certificates/host/ssh_host_ed25519_key-cert.pub b/src/test/resources/certificates/host/ssh_host_ed25519_key-cert.pub
new file mode 100644
index 000000000..85e77f9ac
--- /dev/null
+++ b/src/test/resources/certificates/host/ssh_host_ed25519_key-cert.pub
@@ -0,0 +1 @@
+ssh-ed25519-cert-v01@openssh.com AAAAIHNzaC1lZDI1NTE5LWNlcnQtdjAxQG9wZW5zc2guY29tAAAAIO0sjgw0Lw5NjQOJHNPQfYr6a567Zj9OPNHsAhFryCWaAAAAIL8D5CmrW6ZsQPEihukmIUhSkk5orQF3jgD5ShekkS9vAAAAAAAAAAAAAAACAAAADGhvc3RfZWQyNTUxOQAAAA0AAAAJbG9jYWxob3N0AAAAAAAAAAD//////////wAAAAAAAAAAAAAAAAAAADMAAAALc3NoLWVkMjU1MTkAAAAgpPpGR2s9KWbGPhmLM1NKBIrR67s1T0uBLuElIFw+wXAAAABTAAAAC3NzaC1lZDI1NTE5AAAAQNxk8bNpFEJBmaiv+WxI7rlnY7dGy5oi0vpz2V684kkrITR6VisgomHa2k6lFv+oyrXBS+aNXp5B5sRfr4QQNQY= root@812d5fcc2be2
diff --git a/src/test/resources/certificates/host/ssh_host_ed25519_key.pub b/src/test/resources/certificates/host/ssh_host_ed25519_key.pub
new file mode 100644
index 000000000..fbbc89971
--- /dev/null
+++ b/src/test/resources/certificates/host/ssh_host_ed25519_key.pub
@@ -0,0 +1 @@
+ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIL8D5CmrW6ZsQPEihukmIUhSkk5orQF3jgD5ShekkS9v root@812d5fcc2be2
diff --git a/src/test/resources/certificates/host/ssh_host_rsa_key b/src/test/resources/certificates/host/ssh_host_rsa_key
new file mode 100644
index 000000000..f5c70c7a1
--- /dev/null
+++ b/src/test/resources/certificates/host/ssh_host_rsa_key
@@ -0,0 +1,38 @@
+-----BEGIN OPENSSH PRIVATE KEY-----
+b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAABlwAAAAdzc2gtcn
+NhAAAAAwEAAQAAAYEAxy6jomQ/3xO0nQz0KNWCHhEEe5S9NFlZIYodaOlxJ2PkqryVeJh8
+PQISfK02wnfoKdYH6LtAR+C9xY3K7UjW1sdtXSLH7uDVutNYf8Y29kWxp6TTm3yEP72n7m
+Mp7QqJEuOvz3e0JCL3hvQO3YuWJdAvmnF/hsBN3zPM02Qi6muJKY7ed79JvYQ5Qya9SFWc
+l94pF7/vAiQCIqEgK8Gi4NGoJiRYGmvHXGXZIZ0g0Boe05Y3Mooi0lsvrS2DiXDA9bnW8W
+WH7/1ny9tUyIeFw9+5Ewyt6+OT5rwxg/yenPO/qpYFyRfvocQ75isFP/5DdbtZwtLnUw53
+t/CCuy421opFLea+TWslZrEefQYUMXazQ+43hfr8/uTKQbD2o0KKFt7gaLRY5CeaH1Tt/t
+XBV+NcqQ+5r1f1B57FJdWb2hKjVsEdrqJd/rZoHipMbIfl8C79d/DOZ6dINfHSpPUEl2jM
+T1iYKhi0SzpKw1LzOHizlqjhNkUOIR7CF5Ow/JJlAAAFiPUfrED1H6xAAAAAB3NzaC1yc2
+EAAAGBAMcuo6JkP98TtJ0M9CjVgh4RBHuUvTRZWSGKHWjpcSdj5Kq8lXiYfD0CEnytNsJ3
+6CnWB+i7QEfgvcWNyu1I1tbHbV0ix+7g1brTWH/GNvZFsaek05t8hD+9p+5jKe0KiRLjr8
+93tCQi94b0Dt2LliXQL5pxf4bATd8zzNNkIupriSmO3ne/Sb2EOUMmvUhVnJfeKRe/7wIk
+AiKhICvBouDRqCYkWBprx1xl2SGdINAaHtOWNzKKItJbL60tg4lwwPW51vFlh+/9Z8vbVM
+iHhcPfuRMMrevjk+a8MYP8npzzv6qWBckX76HEO+YrBT/+Q3W7WcLS51MOd7fwgrsuNtaK
+RS3mvk1rJWaxHn0GFDF2s0PuN4X6/P7kykGw9qNCihbe4Gi0WOQnmh9U7f7VwVfjXKkPua
+9X9QeexSXVm9oSo1bBHa6iXf62aB4qTGyH5fAu/XfwzmenSDXx0qT1BJdozE9YmCoYtEs6
+SsNS8zh4s5ao4TZFDiEewheTsPySZQAAAAMBAAEAAAGBAMN6+2+B0cmeblEACJQWzwexDe
+Q3WuWIltg605hGGx5chGwofs2HYc8CPKCN4sNCqOB+RO7c7z5bzAOZoEH2jZrmyGdyniPM
+FxavGxjzsLdMOQnd0yuzLZvdB3YHbntMLrESMlZ8FZitlJ6m4fv+ZZKg2kdKAq1+CC75iJ
+kimr3UYh4eMCn322ga35QO7g+SrgfCKjQ701cXfdz8ozUuaisYuF0OqETt6A+/iTTbH/v8
+1qozr+Jy/a/TfFwK4iA+PfOWhi411xWmwylHLJJImF9NZJ2cKHZAqbeMEpa/HgMurYUvJR
+N4XkaLH6z32n0VObjQX6Y7U5Viu6xDQb3gfxgQrIgvBMhbqAVZpdP4ig7qjGpCvVpYRnMf
+2ZLxPx3oybJEVv11tFH9UfX+gb0LyOSqgtkORFufLPAfop7uljni/XB6h8k7DXrsiJD9ay
+TnJVKHgifN7WfZazz9OHSJqx99LNjcrp5h/MRJkCrTfLLZd979XhZ+FjuU0OajqXML4QAA
+AMAiWYa5vam1+nQCo1qP4CDVeh56uNeCqQDqyljN0LYVo1kL9YVS6wvPt3rditBpWpOUSZ
+kJ48m0QoXAbqD2oEPukkhFvPHkawj9PsW6bO/foKf9K3gGyJVW0bMANFYI75klRGJ5IOAz
+jXK5YwUJk3Yly498WmIPoIGf39sqGH0TctwyPTaGtxYy6jzBesBCvlZGs8sXQfwo9URuww
+xrTGSKOx1G4eLejnq0y1tFf+bko4mLvdUwwzklI5eEmCue3D4AAADBAP9SCSg27Kz4rld8
+D7QwU9MIcF5zQ6iX0mF6HK926VzdH/fMIPVlLsny8XshC6208wDNK68jNb/5nuNwBi339X
+cWD7JUR06ab/At6I1QKRkrWA74qq68gLOJ/13TrYAXYUpp1tsM0m8t46soBykI99qL2hyG
+ukibnPlZqMqHk0SLcyRloiD2IDy4BByFn+LNV2eiQmRA8VxKYHxV3ZHKSLchXPf9cKo+zQ
+uYR5bCE4j9MsyIwwOncJ99AyL9LLOqGQAAAMEAx7ZabxM5zTXUE+VLXsnyz9Wb0rrhR656
+4DWqrQ5iPCQrZAK5vT+VmE75sS7nrUfuEiybYrl1LT5nU+1S809h2kxuyypKzVkbgqElD9
+9yfX7ZrFwDU201nvsLAeEnd/RRe9Civx79mrJleOHPHoDjva//s/AznAo2rk9aTTfcAyos
+sTqmM6h7K/kx0IMf6eRrUXcIPTIcAuz4gwDoitR6KLV2D0Hf1yAxPyNUe7Z561v8PIW2Fd
+CFyjfDpW6nsowtAAAAEXJvb3RAODEyZDVmY2MyYmUyAQ==
+-----END OPENSSH PRIVATE KEY-----
diff --git a/src/test/resources/certificates/host/ssh_host_rsa_key-cert.pub b/src/test/resources/certificates/host/ssh_host_rsa_key-cert.pub
new file mode 100644
index 000000000..85b596e0d
--- /dev/null
+++ b/src/test/resources/certificates/host/ssh_host_rsa_key-cert.pub
@@ -0,0 +1 @@
+ssh-rsa-cert-v01@openssh.com AAAAHHNzaC1yc2EtY2VydC12MDFAb3BlbnNzaC5jb20AAAAgEapArMkpl2zhoJXKdvX+3K0hqM8m22k5NPYP/bryULMAAAADAQABAAABgQDHLqOiZD/fE7SdDPQo1YIeEQR7lL00WVkhih1o6XEnY+SqvJV4mHw9AhJ8rTbCd+gp1gfou0BH4L3FjcrtSNbWx21dIsfu4NW601h/xjb2RbGnpNObfIQ/vafuYyntCokS46/Pd7QkIveG9A7di5Yl0C+acX+GwE3fM8zTZCLqa4kpjt53v0m9hDlDJr1IVZyX3ikXv+8CJAIioSArwaLg0agmJFgaa8dcZdkhnSDQGh7TljcyiiLSWy+tLYOJcMD1udbxZYfv/WfL21TIh4XD37kTDK3r45PmvDGD/J6c87+qlgXJF++hxDvmKwU//kN1u1nC0udTDne38IK7LjbWikUt5r5NayVmsR59BhQxdrND7jeF+vz+5MpBsPajQooW3uBotFjkJ5ofVO3+1cFX41ypD7mvV/UHnsUl1ZvaEqNWwR2uol3+tmgeKkxsh+XwLv138M5np0g18dKk9QSXaMxPWJgqGLRLOkrDUvM4eLOWqOE2RQ4hHsIXk7D8kmUAAAAAAAAAAAAAAAIAAAAIaG9zdF9yc2EAAAANAAAACWxvY2FsaG9zdAAAAAAAAAAA//////////8AAAAAAAAAAAAAAAAAAAAzAAAAC3NzaC1lZDI1NTE5AAAAIKT6RkdrPSlmxj4ZizNTSgSK0eu7NU9LgS7hJSBcPsFwAAAAUwAAAAtzc2gtZWQyNTUxOQAAAEC6TXPaqMTTl3QZrqLnSQ2CDcAD/Xslaf8br4uylFGivfEDmKcB63k9LyL9PlZdtNviAfaM92r55vU8AgiHDh0F root@812d5fcc2be2
diff --git a/src/test/resources/certificates/host/ssh_host_rsa_key.pub b/src/test/resources/certificates/host/ssh_host_rsa_key.pub
new file mode 100644
index 000000000..6b7d09682
--- /dev/null
+++ b/src/test/resources/certificates/host/ssh_host_rsa_key.pub
@@ -0,0 +1 @@
+ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQDHLqOiZD/fE7SdDPQo1YIeEQR7lL00WVkhih1o6XEnY+SqvJV4mHw9AhJ8rTbCd+gp1gfou0BH4L3FjcrtSNbWx21dIsfu4NW601h/xjb2RbGnpNObfIQ/vafuYyntCokS46/Pd7QkIveG9A7di5Yl0C+acX+GwE3fM8zTZCLqa4kpjt53v0m9hDlDJr1IVZyX3ikXv+8CJAIioSArwaLg0agmJFgaa8dcZdkhnSDQGh7TljcyiiLSWy+tLYOJcMD1udbxZYfv/WfL21TIh4XD37kTDK3r45PmvDGD/J6c87+qlgXJF++hxDvmKwU//kN1u1nC0udTDne38IK7LjbWikUt5r5NayVmsR59BhQxdrND7jeF+vz+5MpBsPajQooW3uBotFjkJ5ofVO3+1cFX41ypD7mvV/UHnsUl1ZvaEqNWwR2uol3+tmgeKkxsh+XwLv138M5np0g18dKk9QSXaMxPWJgqGLRLOkrDUvM4eLOWqOE2RQ4hHsIXk7D8kmU= root@812d5fcc2be2
diff --git a/src/test/resources/certificates/host/sshd_config b/src/test/resources/certificates/host/sshd_config
new file mode 100644
index 000000000..82558ff20
--- /dev/null
+++ b/src/test/resources/certificates/host/sshd_config
@@ -0,0 +1,19 @@
+PubkeyAuthentication yes
+
+AuthenticationMethods publickey
+
+PrintMotd no
+PermitRootLogin yes
+Subsystem sftp internal-sftp
+
+HostKey /etc/ssh/ssh_host_rsa_key
+HostCertificate /etc/ssh/ssh_host_rsa_key-cert.pub
+
+HostKey /etc/ssh/ssh_host_ecdsa_key
+HostCertificate /etc/ssh/ssh_host_ecdsa_key-cert.pub
+
+HostKey /etc/ssh/ssh_host_ed25519_key
+HostCertificate /etc/ssh/ssh_host_ed25519_key-cert.pub
+
+TrustedUserCAKeys /etc/ssh/ca_jsch_key.pub
+LogLevel DEBUG3
\ No newline at end of file
diff --git a/src/test/resources/certificates/host/user_keys/id_ecdsa_nistp521 b/src/test/resources/certificates/host/user_keys/id_ecdsa_nistp521
new file mode 100644
index 000000000..88adabfbc
--- /dev/null
+++ b/src/test/resources/certificates/host/user_keys/id_ecdsa_nistp521
@@ -0,0 +1,12 @@
+-----BEGIN OPENSSH PRIVATE KEY-----
+b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAArAAAABNlY2RzYS
+1zaGEyLW5pc3RwNTIxAAAACG5pc3RwNTIxAAAAhQQB9z+oAz0HZplsszpeas0VX48xNLkz
+MtGz2IKBy/pi/DwXJ8eVDWfbq3r1phFeuZwv/KtCkyrKI627heWsHhcCA9cB42DMZZT5yM
+N0y/e4p0EPpnVXzcUs51cnsR6mSriLln3w9UBgTnALp5HVdT9IsJ2DJpAB5AY7p0U8ylT/
+LmBSwQIAAAEQU0MaU1NDGlMAAAATZWNkc2Etc2hhMi1uaXN0cDUyMQAAAAhuaXN0cDUyMQ
+AAAIUEAfc/qAM9B2aZbLM6XmrNFV+PMTS5MzLRs9iCgcv6Yvw8FyfHlQ1n26t69aYRXrmc
+L/yrQpMqyiOtu4XlrB4XAgPXAeNgzGWU+cjDdMv3uKdBD6Z1V83FLOdXJ7Eepkq4i5Z98P
+VAYE5wC6eR1XU/SLCdgyaQAeQGO6dFPMpU/y5gUsECAAAAQgFgeY58EHXf6AicAmc3YTeD
+tJuLYRzSaXCfHtOGShGWkLtVdnhWP3wy1+1zImTpMLEETwoQatVvg+QTL0BFld1lrwAAAB
+FsdWlnaUBleGVtcGxlLmNvbQE=
+-----END OPENSSH PRIVATE KEY-----
diff --git a/src/test/resources/certificates/host/user_keys/id_ecdsa_nistp521.pub b/src/test/resources/certificates/host/user_keys/id_ecdsa_nistp521.pub
new file mode 100644
index 000000000..5dd07a272
--- /dev/null
+++ b/src/test/resources/certificates/host/user_keys/id_ecdsa_nistp521.pub
@@ -0,0 +1 @@
+ecdsa-sha2-nistp521 AAAAE2VjZHNhLXNoYTItbmlzdHA1MjEAAAAIbmlzdHA1MjEAAACFBAH3P6gDPQdmmWyzOl5qzRVfjzE0uTMy0bPYgoHL+mL8PBcnx5UNZ9urevWmEV65nC/8q0KTKsojrbuF5aweFwID1wHjYMxllPnIw3TL97inQQ+mdVfNxSznVyexHqZKuIuWffD1QGBOcAunkdV1P0iwnYMmkAHkBjunRTzKVP8uYFLBAg== luigi@exemple.com
diff --git a/src/test/resources/certificates/known_hosts b/src/test/resources/certificates/known_hosts
new file mode 100644
index 000000000..13b90b620
--- /dev/null
+++ b/src/test/resources/certificates/known_hosts
@@ -0,0 +1 @@
+@cert-authority localhost ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIKT6RkdrPSlmxj4ZizNTSgSK0eu7NU9LgS7hJSBcPsFw ca@jsch
\ No newline at end of file
From 0602fafc06641802e3609524e3a6d78a46afcee5 Mon Sep 17 00:00:00 2001
From: Luigi De Masi
Date: Sat, 18 Oct 2025 14:35:44 +0200
Subject: [PATCH 4/6] Add support for OpenSSH certificates, resolve #31 - Fixes
after code review for Host Certificate support
---
src/main/java/com/jcraft/jsch/Buffer.java | 3 +-
.../java/com/jcraft/jsch/KeyExchange.java | 283 +++++++++---------
.../OpenSshCertificateHostKeyVerifier.java | 35 ++-
src/main/java/com/jcraft/jsch/Session.java | 6 +-
.../com/jcraft/jsch/SignatureWrapper.java | 9 +-
.../com/jcraft/jsch/UserAuthPublicKey.java | 3 +-
.../com/jcraft/jsch/HostCertificateIT.java | 54 ++--
.../resources/certificates/host/Dockerfile | 15 +
8 files changed, 199 insertions(+), 209 deletions(-)
create mode 100644 src/test/resources/certificates/host/Dockerfile
diff --git a/src/main/java/com/jcraft/jsch/Buffer.java b/src/main/java/com/jcraft/jsch/Buffer.java
index c3fc20b63..42462c710 100644
--- a/src/main/java/com/jcraft/jsch/Buffer.java
+++ b/src/main/java/com/jcraft/jsch/Buffer.java
@@ -306,7 +306,7 @@ static Buffer fromBytes(byte[][] args) {
*
* @param bytesToSkip the number of bytes to skip.
*/
- public void readSkip(int bytesToSkip) {
+ void readSkip(int bytesToSkip) {
if (bytesToSkip > getLength()) {
s += getLength();
return;
@@ -314,7 +314,6 @@ public void readSkip(int bytesToSkip) {
s += bytesToSkip;
}
-
/*
* static String[] chars={ "0","1","2","3","4","5","6","7","8","9", "a","b","c","d","e","f" };
* static void dump_buffer(){ int foo; for(int i=0; i c =
- Class.forName(session.getConfig(foo)).asSubclass(SignatureRSA.class);
- sig = c.getDeclaredConstructor().newInstance();
- sig.init();
- } catch (Exception e) {
- throw new JSchException(e.toString(), e);
- }
- sig.setPubKey(ee, n);
- sig.update(H);
- result = sig.verify(sig_of_H);
+ if ("ssh-rsa".equals(alg)) {
+ byte[] ee;
+ byte[] n;
+
+ this.type = RSA;
+ key_alg_name = alg;
+
+ ee = buffer.getMPInt();
+ n = buffer.getMPInt();
+
+ SignatureRSA sig;
+ Buffer buf = new Buffer(sig_of_H);
+ String foo = Util.byte2str(buf.getString());
+ try {
+ Class extends SignatureRSA> c =
+ Class.forName(session.getConfig(foo)).asSubclass(SignatureRSA.class);
+ sig = c.getDeclaredConstructor().newInstance();
+ sig.init();
+ } catch (Exception e) {
+ throw new JSchException(e.toString(), e);
+ }
+ sig.setPubKey(ee, n);
+ sig.update(H);
+ result = sig.verify(sig_of_H);
- if (session.getLogger().isEnabled(Logger.INFO)) {
- session.getLogger().log(Logger.INFO, "ssh_rsa_verify: " + foo + " signature " + result);
- }
- return result;
-
- case "ssh-dss":
- byte[] q;
- byte[] p;
- byte[] g;
- byte[] y;
-
- type = DSS;
- key_alg_name = alg;
-
- p = buffer.getMPInt();
- q = buffer.getMPInt();
- g = buffer.getMPInt();
- y = buffer.getMPInt();
-
- SignatureDSA sigDSA;
- try {
- Class extends SignatureDSA> c =
- Class.forName(session.getConfig("signature.dss")).asSubclass(SignatureDSA.class);
- sigDSA = c.getDeclaredConstructor().newInstance();
- sigDSA.init();
- } catch (Exception e) {
- throw new JSchException(e.toString(), e);
- }
- sigDSA.setPubKey(y, p, q, g);
- sigDSA.update(H);
- result = sigDSA.verify(sig_of_H);
+ if (session.getLogger().isEnabled(Logger.INFO)) {
+ session.getLogger().log(Logger.INFO, "ssh_rsa_verify: " + foo + " signature " + result);
+ }
+ } else if ("ssh-dss".equals(alg)) {
+ byte[] q;
+ byte[] p;
+ byte[] g;
+ byte[] y;
+
+ type = DSS;
+ key_alg_name = alg;
+
+ p = buffer.getMPInt();
+ q = buffer.getMPInt();
+ g = buffer.getMPInt();
+ y = buffer.getMPInt();
+
+ SignatureDSA sigDSA;
+ try {
+ Class extends SignatureDSA> c =
+ Class.forName(session.getConfig("signature.dss")).asSubclass(SignatureDSA.class);
+ sigDSA = c.getDeclaredConstructor().newInstance();
+ sigDSA.init();
+ } catch (Exception e) {
+ throw new JSchException(e.toString(), e);
+ }
+ sigDSA.setPubKey(y, p, q, g);
+ sigDSA.update(H);
+ result = sigDSA.verify(sig_of_H);
- if (session.getLogger().isEnabled(Logger.INFO)) {
- session.getLogger().log(Logger.INFO, "ssh_dss_verify: signature " + result);
- }
- return result;
-
- case "ecdsa-sha2-nistp256":
- case "ecdsa-sha2-nistp384":
- case "ecdsa-sha2-nistp521":
-
- byte[] r;
- byte[] s;
-
- // RFC 5656,
- type = ECDSA;
- this.key_alg_name = alg;
-
- // https://www.rfc-editor.org/rfc/rfc5656#section-3.1
- // The string [identifier] is the identifier of the elliptic curve domain parameters.
- String identifier = byte2str(buffer.getString());
-
- int len = buffer.getInt();
- int x04 = buffer.getByte();
- r = new byte[(len - 1) / 2];
- s = new byte[(len - 1) / 2];
- buffer.getByte(r);
- buffer.getByte(s);
-
- SignatureECDSA sigECDSA = null;
- try {
- Class extends SignatureECDSA> c =
- Class.forName(session.getConfig(alg)).asSubclass(SignatureECDSA.class);
- sigECDSA = c.getDeclaredConstructor().newInstance();
- sigECDSA.init();
- } catch (Exception e) {
- throw new JSchException(e.toString(), e);
- }
+ if (session.getLogger().isEnabled(Logger.INFO)) {
+ session.getLogger().log(Logger.INFO, "ssh_dss_verify: signature " + result);
+ }
+ } else if ("ecdsa-sha2-nistp256".equals(alg) || "ecdsa-sha2-nistp384".equals(alg)
+ || "ecdsa-sha2-nistp521".equals(alg)) {
+ byte[] r;
+ byte[] s;
+
+ // RFC 5656,
+ type = ECDSA;
+ this.key_alg_name = alg;
+
+ // https://www.rfc-editor.org/rfc/rfc5656#section-3.1
+ // The string [identifier] is the identifier of the elliptic curve domain parameters.
+ String identifier = Util.byte2str(buffer.getString());
+
+ int len = buffer.getInt();
+ int x04 = buffer.getByte();
+ r = new byte[(len - 1) / 2];
+ s = new byte[(len - 1) / 2];
+ buffer.getByte(r);
+ buffer.getByte(s);
+
+ SignatureECDSA sigECDSA = null;
+ try {
+ Class extends SignatureECDSA> c =
+ Class.forName(session.getConfig(alg)).asSubclass(SignatureECDSA.class);
+ sigECDSA = c.getDeclaredConstructor().newInstance();
+ sigECDSA.init();
+ } catch (Exception e) {
+ throw new JSchException(e.toString(), e);
+ }
- sigECDSA.setPubKey(r, s);
- sigECDSA.update(H);
- result = sigECDSA.verify(sig_of_H);
+ sigECDSA.setPubKey(r, s);
+ sigECDSA.update(H);
+ result = sigECDSA.verify(sig_of_H);
- if (session.getLogger().isEnabled(Logger.INFO)) {
- session.getLogger().log(Logger.INFO, "ssh_ecdsa_verify: " + alg + " signature " + result);
- }
- return result;
-
- case "ssh-ed25519":
- case "ssh-ed448":
-
- // RFC 8709,
- type = EDDSA;
- key_alg_name = alg;
- int keyLength = buffer.getInt();
- byte[] edXXX_pub_array = new byte[keyLength];
- buffer.getByte(edXXX_pub_array);
-
- SignatureEdDSA sigEdDSA = null;
- try {
- Class extends SignatureEdDSA> c =
- Class.forName(session.getConfig(alg)).asSubclass(SignatureEdDSA.class);
- sigEdDSA = c.getDeclaredConstructor().newInstance();
- sigEdDSA.init();
- } catch (Exception | LinkageError e) {
- throw new JSchException(e.toString(), e);
- }
+ if (session.getLogger().isEnabled(Logger.INFO)) {
+ session.getLogger().log(Logger.INFO, "ssh_ecdsa_verify: " + alg + " signature " + result);
+ }
+ } else if ("ssh-ed25519".equals(alg) || "ssh-ed448".equals(alg)) {
+ // RFC 8709,
+ type = EDDSA;
+ key_alg_name = alg;
+ int keyLength = buffer.getInt();
+ byte[] edXXX_pub_array = new byte[keyLength];
+ buffer.getByte(edXXX_pub_array);
+
+ SignatureEdDSA sigEdDSA = null;
+ try {
+ Class extends SignatureEdDSA> c =
+ Class.forName(session.getConfig(alg)).asSubclass(SignatureEdDSA.class);
+ sigEdDSA = c.getDeclaredConstructor().newInstance();
+ sigEdDSA.init();
+ } catch (Exception | LinkageError e) {
+ throw new JSchException(e.toString(), e);
+ }
- sigEdDSA.setPubKey(edXXX_pub_array);
+ sigEdDSA.setPubKey(edXXX_pub_array);
- sigEdDSA.update(H);
+ sigEdDSA.update(H);
- result = sigEdDSA.verify(sig_of_H);
+ result = sigEdDSA.verify(sig_of_H);
- if (session.getLogger().isEnabled(Logger.INFO)) {
- session.getLogger().log(Logger.INFO, "ssh_eddsa_verify: " + alg + " signature " + result);
- }
- return result;
- default:
- if (session.getLogger().isEnabled(Logger.ERROR)) {
- session.getLogger().log(Logger.ERROR, "unknown alg: " + alg);
- }
- return result;
+ if (session.getLogger().isEnabled(Logger.INFO)) {
+ session.getLogger().log(Logger.INFO, "ssh_eddsa_verify: " + alg + " signature " + result);
+ }
+ } else {
+ if (session.getLogger().isEnabled(Logger.ERROR)) {
+ session.getLogger().log(Logger.ERROR, "unknown alg: " + alg);
+ }
}
+ return result;
+
}
protected byte[] encodeInt(int raw) {
diff --git a/src/main/java/com/jcraft/jsch/OpenSshCertificateHostKeyVerifier.java b/src/main/java/com/jcraft/jsch/OpenSshCertificateHostKeyVerifier.java
index 8c78e4fbd..944600932 100644
--- a/src/main/java/com/jcraft/jsch/OpenSshCertificateHostKeyVerifier.java
+++ b/src/main/java/com/jcraft/jsch/OpenSshCertificateHostKeyVerifier.java
@@ -37,11 +37,11 @@ public class OpenSshCertificateHostKeyVerifier {
*
* @param session the current JSch session.
* @param kex the key exchange context, which contains the host certificate.
- * @throws Exception if the certificate is invalid, expired, not signed by a trusted CA, or fails
- * any other validation check. Throws specific subclasses of {@link JSchException} for
- * different failure reasons.
+ * @throws JSchException if the certificate is invalid, expired, not signed by a trusted CA, or
+ * fails any other validation check. Throws specific subclasses of {@link JSchException}
+ * for different failure reasons.
*/
- public static void checkHostCertificate(Session session, KeyExchange kex) throws Exception {
+ public static void checkHostCertificate(Session session, KeyExchange kex) throws JSchException {
OpenSshCertificate certificate = kex.getHostKeyCertificate();
byte[] caPublicKeyByteArray = certificate.getSignatureKey();
@@ -103,9 +103,9 @@ public static void checkHostCertificate(Session session, KeyExchange kex) throws
* @param certificatePublicKey the raw byte array of the public key blob.
* @return A 2D byte array where each inner array is a component of the public key (e.g., for RSA,
* it returns {exponent, modulus}).
- * @throws Exception if the public key algorithm is unknown or the key format is corrupt.
+ * @throws JSchException if the public key algorithm is unknown or the key format is corrupt.
*/
- public static byte[][] parsePublicKey(byte[] certificatePublicKey) throws Exception {
+ public static byte[][] parsePublicKey(byte[] certificatePublicKey) throws JSchException {
Buffer buffer = new Buffer(certificatePublicKey);
String algorithm = byte2str(buffer.getString());
@@ -155,20 +155,25 @@ public static byte[][] parsePublicKey(byte[] certificatePublicKey) throws Except
*
* @param certificate the certificate to verify.
* @param caPublicKeyAlgorithm the algorithm of the CA's public key.
- * @throws Exception if the signature algorithm does not match the CA key algorithm or if the
+ * @throws JSchException if the signature algorithm does not match the CA key algorithm or if the
* signature is cryptographically invalid.
*/
private static void checkSignature(OpenSshCertificate certificate, String caPublicKeyAlgorithm)
- throws Exception {
+ throws JSchException {
// Check signature
SignatureWrapper signature = getSignatureWrapper(certificate, caPublicKeyAlgorithm);
byte[][] publicKey = parsePublicKey(certificate.getSignatureKey());
+ boolean verified;
+ try {
+ signature.init();
+ signature.setPubKey(publicKey);
+ signature.update(certificate.getMessage());
+ verified = signature.verify(certificate.getSignature());
+ } catch (Exception e) {
+ throw new JSchException("invalid signature key", e);
+ }
- signature.init();
- signature.setPubKey(publicKey);
- signature.update(certificate.getMessage());
-
- if (!signature.verify(certificate.getSignature())) {
+ if (!verified) {
throw new JSchInvalidHostCertificateException(
"rejected HostKey: signature verification failed");
}
@@ -211,9 +216,9 @@ private static SignatureWrapper getSignatureWrapper(OpenSshCertificate certifica
*
* @param knownHosts the repository of known hosts (typically from a known_hosts file).
* @return a {@link Set} of {@link HostKey} objects representing the trusted CAs.
- * @throws Exception if there is an error accessing the host key repository.
*/
- private static Set getTrustedCAs(HostKeyRepository knownHosts) throws Exception {
+
+ private static Set getTrustedCAs(HostKeyRepository knownHosts) {
HostKey[] hostKeys = knownHosts.getHostKey();
return hostKeys == null ? new HashSet<>()
: Arrays.stream(hostKeys).filter(OpenSshCertificateUtil::isKnownHostCaPublicKeyEntry)
diff --git a/src/main/java/com/jcraft/jsch/Session.java b/src/main/java/com/jcraft/jsch/Session.java
index 9663a6119..ab90150fa 100644
--- a/src/main/java/com/jcraft/jsch/Session.java
+++ b/src/main/java/com/jcraft/jsch/Session.java
@@ -928,14 +928,12 @@ private void send_extinfo() throws Exception {
}
}
-
-
private void checkHost(String chost, int port, KeyExchange kex) throws Exception {
if (!kex.isOpenSshServerHostKeyType) {
checkHostKey(chost, port, kex);
return;
}
- checkHostCertificate(this, kex);
+ OpenSshCertificateHostKeyVerifier.checkHostCertificate(this, kex);
}
private void checkHostKey(String chost, int port, KeyExchange kex) throws JSchException {
@@ -1184,6 +1182,8 @@ Buffer read(Buffer buf) throws Exception {
boolean isAEAD = (s2ccipher != null && s2ccipher.isAEAD());
boolean isEtM =
(!isChaCha20 && !isAEAD && s2ccipher != null && s2cmac != null && s2cmac.isEtM());
+
+ // Decrypting
while (true) {
buf.reset();
if (isChaCha20) {
diff --git a/src/main/java/com/jcraft/jsch/SignatureWrapper.java b/src/main/java/com/jcraft/jsch/SignatureWrapper.java
index f570b4455..c71b20012 100644
--- a/src/main/java/com/jcraft/jsch/SignatureWrapper.java
+++ b/src/main/java/com/jcraft/jsch/SignatureWrapper.java
@@ -1,6 +1,6 @@
package com.jcraft.jsch;
-import static java.nio.charset.StandardCharsets.UTF_8;
+import java.nio.charset.StandardCharsets;
/**
* A factory and wrapper class for creating and managing digital signature instances.
@@ -11,7 +11,7 @@
* sign) by delegating calls to the - * underlying signature instance.
*
*/
-public class SignatureWrapper implements Signature {
+class SignatureWrapper implements Signature {
private final Signature signature;
@@ -28,7 +28,7 @@ public class SignatureWrapper implements Signature {
* instance cannot be created.
*/
public SignatureWrapper(byte[] dataSignature) throws JSchException {
- this(new OpenSshCertificateBuffer(dataSignature).getString(UTF_8));
+ this(new OpenSshCertificateBuffer(dataSignature).getString(StandardCharsets.UTF_8));
}
/**
@@ -90,7 +90,6 @@ private static PubKeyParameterValidator generateValidator(String algorithm,
};
}
-
/**
* Initializes the underlying signature instance for signing or verification. This method
* delegates the call to the wrapped signature object.
@@ -139,7 +138,6 @@ public byte[] sign() throws Exception {
return signature.sign();
}
-
/**
* Sets the public key required for signature verification.
*
@@ -156,7 +154,6 @@ public void setPubKey(byte[]... args) throws Exception {
publicKeySetter.setPubKey(args);
}
-
/**
* A functional interface for setting a public key on a {@link Signature} instance.
*/
diff --git a/src/main/java/com/jcraft/jsch/UserAuthPublicKey.java b/src/main/java/com/jcraft/jsch/UserAuthPublicKey.java
index 1f9721be2..4a458bf5a 100644
--- a/src/main/java/com/jcraft/jsch/UserAuthPublicKey.java
+++ b/src/main/java/com/jcraft/jsch/UserAuthPublicKey.java
@@ -33,7 +33,6 @@
import java.util.List;
import java.util.Vector;
-import static com.jcraft.jsch.OpenSshCertificateUtil.getRawKeyType;
class UserAuthPublicKey extends UserAuth {
@@ -77,7 +76,7 @@ public boolean start(Session session) throws Exception {
boolean add = false;
for (String server_sig_alg : server_sig_algs) {
// This cover the case of the public key is in Openssh certificate format.
- String pkRawMethod = getRawKeyType(pkmethod);
+ String pkRawMethod = OpenSshCertificateUtil.getRawKeyType(pkmethod);
if (pkmethod.equals(server_sig_alg)
|| (pkRawMethod != null && pkRawMethod.equals(server_sig_alg))) {
add = true;
diff --git a/src/test/java/com/jcraft/jsch/HostCertificateIT.java b/src/test/java/com/jcraft/jsch/HostCertificateIT.java
index 0c2ab2869..bbc21b8d8 100644
--- a/src/test/java/com/jcraft/jsch/HostCertificateIT.java
+++ b/src/test/java/com/jcraft/jsch/HostCertificateIT.java
@@ -8,10 +8,12 @@
import org.testcontainers.containers.GenericContainer;
import org.testcontainers.containers.wait.strategy.Wait;
import org.testcontainers.images.builder.ImageFromDockerfile;
+import org.testcontainers.images.builder.dockerfile.DockerfileBuilder;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
import java.util.Arrays;
+import java.util.List;
import static com.jcraft.jsch.ResourceUtil.getResourceFile;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
@@ -52,39 +54,23 @@ public class HostCertificateIT {
*
*/
@Container
- private GenericContainer> sshdContainer =
- new GenericContainer<>(new ImageFromDockerfile("jsch_host_key_test", false)
- .withDockerfileFromBuilder(builder -> builder.from("alpine:3.16")
- .run("apk add --update openssh openssh-server bash && " + "rm /var/cache/apk/*")
- .run("adduser -D luigi") // Add a user
- .run("echo 'luigi:passwordLuigi' | chpasswd") // Unlock the user
- .run("mkdir -p /home/luigi/.ssh").copy("sshd_config", "/etc/ssh/sshd_config")
- .copy("authorized_keys", "/home/luigi/.ssh/authorized_keys")
- .copy("entrypoint.sh", "/entrypoint.sh")
- .copy("ssh_host_rsa_key", "/etc/ssh/ssh_host_rsa_key")
- .copy("ssh_host_rsa_key-cert.pub", "/etc/ssh/ssh_host_rsa_key-cert.pub")
- .copy("ssh_host_ecdsa_key", "/etc/ssh/ssh_host_ecdsa_key")
- .copy("ssh_host_ecdsa_key-cert.pub", "/etc/ssh/ssh_host_ecdsa_key-cert.pub")
- .copy("ssh_host_ed25519_key", "/etc/ssh/ssh_host_ed25519_key")
- .copy("ssh_host_ed25519_key-cert.pub", "/etc/ssh/ssh_host_ed25519_key-cert.pub")
- .entryPoint("/entrypoint.sh").build())
- .withFileFromClasspath("sshd_config", CERTIFICATES_BASE_FOLDER + "/sshd_config")
- .withFileFromClasspath("authorized_keys",
- CERTIFICATES_BASE_FOLDER + "/user_keys/id_ecdsa_nistp521.pub")
- .withFileFromClasspath("entrypoint.sh", CERTIFICATES_BASE_FOLDER + "/entrypoint.sh")
- .withFileFromClasspath("ssh_host_rsa_key", CERTIFICATES_BASE_FOLDER + "/ssh_host_rsa_key")
- .withFileFromClasspath("ssh_host_rsa_key-cert.pub",
- CERTIFICATES_BASE_FOLDER + "/ssh_host_rsa_key-cert.pub")
- .withFileFromClasspath("ssh_host_ecdsa_key",
- CERTIFICATES_BASE_FOLDER + "/ssh_host_ecdsa_key")
- .withFileFromClasspath("ssh_host_ecdsa_key-cert.pub",
- CERTIFICATES_BASE_FOLDER + "/ssh_host_ecdsa_key-cert.pub")
- .withFileFromClasspath("ssh_host_ed25519_key",
- CERTIFICATES_BASE_FOLDER + "/ssh_host_ed25519_key")
- .withFileFromClasspath("ssh_host_ed25519_key-cert.pub",
- CERTIFICATES_BASE_FOLDER + "/ssh_host_ed25519_key-cert.pub"))
- .withExposedPorts(22)
- .waitingFor(Wait.forLogMessage(".*Server listening on :: port 22.*", 1));
+ private GenericContainer> sshdContainer = new GenericContainer<>(new ImageFromDockerfile()
+ .withFileFromClasspath("sshd_config", CERTIFICATES_BASE_FOLDER + "/sshd_config")
+ .withFileFromClasspath("authorized_keys",
+ CERTIFICATES_BASE_FOLDER + "/user_keys/id_ecdsa_nistp521.pub")
+ .withFileFromClasspath("entrypoint.sh", CERTIFICATES_BASE_FOLDER + "/entrypoint.sh")
+ .withFileFromClasspath("ssh_host_rsa_key", CERTIFICATES_BASE_FOLDER + "/ssh_host_rsa_key")
+ .withFileFromClasspath("ssh_host_rsa_key-cert.pub",
+ CERTIFICATES_BASE_FOLDER + "/ssh_host_rsa_key-cert.pub")
+ .withFileFromClasspath("ssh_host_ecdsa_key", CERTIFICATES_BASE_FOLDER + "/ssh_host_ecdsa_key")
+ .withFileFromClasspath("ssh_host_ecdsa_key-cert.pub",
+ CERTIFICATES_BASE_FOLDER + "/ssh_host_ecdsa_key-cert.pub")
+ .withFileFromClasspath("ssh_host_ed25519_key",
+ CERTIFICATES_BASE_FOLDER + "/ssh_host_ed25519_key")
+ .withFileFromClasspath("ssh_host_ed25519_key-cert.pub",
+ CERTIFICATES_BASE_FOLDER + "/ssh_host_ed25519_key-cert.pub")
+ .withFileFromClasspath("Dockerfile", CERTIFICATES_BASE_FOLDER + "/Dockerfile"))
+ .withExposedPorts(22).waitingFor(Wait.forLogMessage(".*Server listening on :: port 22.*", 1));
/**
@@ -93,7 +79,7 @@ public class HostCertificateIT {
*
* @return An iterable of host key algorithm strings for test parameterization.
*/
- public static Iterable extends String> privateKeyParams() {
+ public static List privateKeyParams() {
return Arrays.asList("ssh-ed25519-cert-v01@openssh.com",
"ecdsa-sha2-nistp256-cert-v01@openssh.com,ecdsa-sha2-nistp384-cert-v01@openssh.com,ecdsa-sha2-nistp521-cert-v01@openssh.com",
"ssh-rsa-cert-v01@openssh.com,rsa-sha2-512-cert-v01@openssh.com,rsa-sha2-256-cert-v01@openssh.com");
diff --git a/src/test/resources/certificates/host/Dockerfile b/src/test/resources/certificates/host/Dockerfile
new file mode 100644
index 000000000..68464ea8b
--- /dev/null
+++ b/src/test/resources/certificates/host/Dockerfile
@@ -0,0 +1,15 @@
+FROM alpine:3.16
+RUN apk add --update openssh openssh-server bash && rm /var/cache/apk/*
+RUN adduser -D luigi
+RUN echo 'luigi:passwordLuigi' | chpasswd
+RUN mkdir -p /home/luigi/.ssh
+COPY ["sshd_config","/etc/ssh/sshd_config"]
+COPY ["authorized_keys","/home/luigi/.ssh/authorized_keys"]
+COPY ["entrypoint.sh","/entrypoint.sh"]
+COPY ["ssh_host_rsa_key","/etc/ssh/ssh_host_rsa_key"]
+COPY ["ssh_host_rsa_key-cert.pub","/etc/ssh/ssh_host_rsa_key-cert.pub"]
+COPY ["ssh_host_ecdsa_key","/etc/ssh/ssh_host_ecdsa_key"]
+COPY ["ssh_host_ecdsa_key-cert.pub","/etc/ssh/ssh_host_ecdsa_key-cert.pub"]
+COPY ["ssh_host_ed25519_key","/etc/ssh/ssh_host_ed25519_key"]
+COPY ["ssh_host_ed25519_key-cert.pub","/etc/ssh/ssh_host_ed25519_key-cert.pub"]
+ENTRYPOINT /entrypoint.sh
\ No newline at end of file
From 3dd722033b95705fd82567636093060790e74504 Mon Sep 17 00:00:00 2001
From: Luigi De Masi
Date: Wed, 22 Oct 2025 01:28:00 +0200
Subject: [PATCH 5/6] Add support for OpenSSH certificates, resolve #31 - Fixes
after code review for Host Certificate support - part2
---
src/main/java/com/jcraft/jsch/DHECN.java | 2 +-
src/main/java/com/jcraft/jsch/DHECNKEM.java | 2 +-
src/main/java/com/jcraft/jsch/DHGEX.java | 2 +-
src/main/java/com/jcraft/jsch/DHGN.java | 2 +-
src/main/java/com/jcraft/jsch/DHXEC.java | 2 +-
src/main/java/com/jcraft/jsch/DHXECKEM.java | 2 +-
src/main/java/com/jcraft/jsch/HostKey.java | 122 +++++
src/main/java/com/jcraft/jsch/JSch.java | 19 +-
.../java/com/jcraft/jsch/KeyExchange.java | 264 ++++++---
.../com/jcraft/jsch/OpenSshCertificate.java | 88 +--
.../OpenSshCertificateAwareIdentityFile.java | 185 ++++---
.../jcraft/jsch/OpenSshCertificateBuffer.java | 45 +-
.../OpenSshCertificateHostKeyVerifier.java | 77 ++-
.../jcraft/jsch/OpenSshCertificateParser.java | 49 +-
.../jcraft/jsch/OpenSshCertificateUtil.java | 418 ++++++++++++---
src/main/java/com/jcraft/jsch/Session.java | 51 +-
.../com/jcraft/jsch/SignatureWrapper.java | 19 +-
.../com/jcraft/jsch/UserAuthPublicKey.java | 1 -
src/main/java/com/jcraft/jsch/Util.java | 4 +
.../com/jcraft/jsch/HostCertificateIT.java | 12 +-
.../java/com/jcraft/jsch/HostKeyTest.java | 269 ++++++++++
...enSshCertificateAwareIdentityFileTest.java | 321 +++++++++++
.../jsch/OpenSshCertificateUtilTest.java | 502 ++++++++++++++++++
.../java/com/jcraft/jsch/UserCertAuthIT.java | 34 +-
.../resources/certificates/host/Dockerfile | 6 +-
25 files changed, 2022 insertions(+), 476 deletions(-)
create mode 100644 src/test/java/com/jcraft/jsch/HostKeyTest.java
create mode 100644 src/test/java/com/jcraft/jsch/OpenSshCertificateAwareIdentityFileTest.java
create mode 100644 src/test/java/com/jcraft/jsch/OpenSshCertificateUtilTest.java
diff --git a/src/main/java/com/jcraft/jsch/DHECN.java b/src/main/java/com/jcraft/jsch/DHECN.java
index 12136ec1d..6e0963bec 100644
--- a/src/main/java/com/jcraft/jsch/DHECN.java
+++ b/src/main/java/com/jcraft/jsch/DHECN.java
@@ -174,7 +174,7 @@ public boolean doNext(Buffer _buf, int sshMessageType) throws Exception {
String alg = Util.byte2str(K_S, i, j);
i += j;
- boolean result = verifyKeyExchangeServerSignature(alg, K_S, i, sig_of_H);
+ boolean result = verify(alg, K_S, i, sig_of_H);
state = STATE_END;
return result;
diff --git a/src/main/java/com/jcraft/jsch/DHECNKEM.java b/src/main/java/com/jcraft/jsch/DHECNKEM.java
index b73b6ae75..8023cf401 100644
--- a/src/main/java/com/jcraft/jsch/DHECNKEM.java
+++ b/src/main/java/com/jcraft/jsch/DHECNKEM.java
@@ -220,7 +220,7 @@ public boolean doNext(Buffer _buf, int sshMessageType) throws Exception {
String alg = Util.byte2str(K_S, i, j);
i += j;
- boolean result = verifyKeyExchangeServerSignature(alg, K_S, i, sig_of_H);
+ boolean result = verify(alg, K_S, i, sig_of_H);
state = STATE_END;
return result;
diff --git a/src/main/java/com/jcraft/jsch/DHGEX.java b/src/main/java/com/jcraft/jsch/DHGEX.java
index f718dd8a6..6d0c47649 100644
--- a/src/main/java/com/jcraft/jsch/DHGEX.java
+++ b/src/main/java/com/jcraft/jsch/DHGEX.java
@@ -233,7 +233,7 @@ public boolean doNext(Buffer _buf, int sshMessageType) throws Exception {
String alg = Util.byte2str(K_S, i, j);
i += j;
- boolean result = verifyKeyExchangeServerSignature(alg, K_S, i, sig_of_H);
+ boolean result = verify(alg, K_S, i, sig_of_H);
state = STATE_END;
return result;
diff --git a/src/main/java/com/jcraft/jsch/DHGN.java b/src/main/java/com/jcraft/jsch/DHGN.java
index fda68209a..e8b0296a0 100644
--- a/src/main/java/com/jcraft/jsch/DHGN.java
+++ b/src/main/java/com/jcraft/jsch/DHGN.java
@@ -172,7 +172,7 @@ public boolean doNext(Buffer _buf, int sshMessageType) throws Exception {
String alg = Util.byte2str(K_S, i, j);
i += j;
- boolean result = verifyKeyExchangeServerSignature(alg, K_S, i, sig_of_H);
+ boolean result = verify(alg, K_S, i, sig_of_H);
state = STATE_END;
return result;
diff --git a/src/main/java/com/jcraft/jsch/DHXEC.java b/src/main/java/com/jcraft/jsch/DHXEC.java
index d8ba71cda..e885ec449 100644
--- a/src/main/java/com/jcraft/jsch/DHXEC.java
+++ b/src/main/java/com/jcraft/jsch/DHXEC.java
@@ -186,7 +186,7 @@ public boolean doNext(Buffer _buf, int sshMessageType) throws Exception {
String alg = Util.byte2str(K_S, i, j);
i += j;
- boolean result = verifyKeyExchangeServerSignature(alg, K_S, i, sig_of_H);
+ boolean result = verify(alg, K_S, i, sig_of_H);
state = STATE_END;
return result;
diff --git a/src/main/java/com/jcraft/jsch/DHXECKEM.java b/src/main/java/com/jcraft/jsch/DHXECKEM.java
index 192e6226d..8d2b83664 100644
--- a/src/main/java/com/jcraft/jsch/DHXECKEM.java
+++ b/src/main/java/com/jcraft/jsch/DHXECKEM.java
@@ -215,7 +215,7 @@ public boolean doNext(Buffer _buf, int sshMessageType) throws Exception {
String alg = Util.byte2str(K_S, i, j);
i += j;
- boolean result = verifyKeyExchangeServerSignature(alg, K_S, i, sig_of_H);
+ boolean result = verify(alg, K_S, i, sig_of_H);
state = STATE_END;
return result;
diff --git a/src/main/java/com/jcraft/jsch/HostKey.java b/src/main/java/com/jcraft/jsch/HostKey.java
index 112478a53..efce739e1 100644
--- a/src/main/java/com/jcraft/jsch/HostKey.java
+++ b/src/main/java/com/jcraft/jsch/HostKey.java
@@ -143,6 +143,128 @@ boolean isMatched(String _host) {
return isIncluded(_host);
}
+ /**
+ * Checks if the given hostname matches any of the host patterns in this HostKey, supporting
+ * OpenSSH-style wildcards.
+ *
+ * This method supports wildcard patterns similar to OpenSSH's known_hosts file:
+ *
+ *
+ * - {@code *} - Matches zero or more characters
+ * - {@code ?} - Matches exactly one character
+ *
+ *
+ * The host field can contain multiple comma-separated patterns. The method returns {@code true}
+ * if the hostname matches ANY of the patterns.
+ *
+ *
+ * Examples:
+ *
+ *
+ * - {@code *.example.com} matches {@code host.example.com}, {@code sub.example.com}
+ * - {@code host?.example.com} matches {@code host1.example.com}, {@code hosta.example.com}
+ * - {@code 192.168.1.*} matches {@code 192.168.1.1}, {@code 192.168.1.100}
+ * - {@code host1.com,*.host2.com} matches {@code host1.com} or any subdomain of
+ * {@code host2.com}
+ *
+ *
+ * @param _host the hostname to test against the patterns, must not be {@code null}
+ * @return {@code true} if the hostname matches any of the patterns (with wildcard support);
+ * {@code false} otherwise
+ * @see #isMatched(String)
+ */
+ boolean isWildcardMatched(String _host) {
+ if (_host == null) {
+ return false;
+ }
+
+ String hosts = this.host;
+ if (hosts == null || hosts.isEmpty()) {
+ return false;
+ }
+
+ // Split by comma and check each pattern
+ int i = 0;
+ int hostslen = hosts.length();
+ while (i < hostslen) {
+ int j = hosts.indexOf(',', i);
+ String pattern;
+ if (j == -1) {
+ pattern = hosts.substring(i).trim();
+ if (matchesWildcardPattern(pattern, _host)) {
+ return true;
+ }
+ break;
+ } else {
+ pattern = hosts.substring(i, j).trim();
+ if (matchesWildcardPattern(pattern, _host)) {
+ return true;
+ }
+ i = j + 1;
+ }
+ }
+ return false;
+ }
+
+ /**
+ * Tests if a hostname matches a single wildcard pattern.
+ *
+ * This method implements wildcard matching similar to OpenSSH, supporting:
+ *
+ *
+ * - {@code *} - Matches zero or more characters
+ * - {@code ?} - Matches exactly one character
+ *
+ *
+ * The matching is case-sensitive to maintain consistency with OpenSSH behavior.
+ *
+ *
+ * @param pattern the wildcard pattern to match against (e.g., {@code *.example.com})
+ * @param hostname the hostname to test (e.g., {@code host.example.com})
+ * @return {@code true} if the hostname matches the pattern; {@code false} otherwise
+ */
+ private boolean matchesWildcardPattern(String pattern, String hostname) {
+ if (pattern == null || hostname == null) {
+ return false;
+ }
+
+ int pLen = pattern.length();
+ int hLen = hostname.length();
+ int p = 0; // pattern index
+ int h = 0; // hostname index
+ int starIdx = -1; // last '*' position in pattern
+ int matchIdx = 0; // position in hostname after last '*' match
+
+ while (h < hLen) {
+ if (p < pLen && (pattern.charAt(p) == '?' || pattern.charAt(p) == hostname.charAt(h))) {
+ // Match single character or '?'
+ p++;
+ h++;
+ } else if (p < pLen && pattern.charAt(p) == '*') {
+ // Found '*', record position and try to match rest
+ starIdx = p;
+ matchIdx = h;
+ p++;
+ } else if (starIdx != -1) {
+ // No match, but we have a previous '*', backtrack
+ p = starIdx + 1;
+ matchIdx++;
+ h = matchIdx;
+ } else {
+ // No match and no '*' to backtrack to
+ return false;
+ }
+ }
+
+ // Process remaining pattern characters (should be all '*')
+ while (p < pLen && pattern.charAt(p) == '*') {
+ p++;
+ }
+
+ // Match if we've consumed entire pattern
+ return p == pLen;
+ }
+
private boolean isIncluded(String _host) {
int i = 0;
String hosts = this.host;
diff --git a/src/main/java/com/jcraft/jsch/JSch.java b/src/main/java/com/jcraft/jsch/JSch.java
index 2b5a98542..e2b2df7bb 100644
--- a/src/main/java/com/jcraft/jsch/JSch.java
+++ b/src/main/java/com/jcraft/jsch/JSch.java
@@ -26,7 +26,6 @@
package com.jcraft.jsch;
-
import java.io.IOException;
import java.io.InputStream;
import java.util.Collections;
@@ -36,9 +35,6 @@
import java.util.Map;
import java.util.Vector;
-import static com.jcraft.jsch.OpenSshCertificateAwareIdentityFile.isOpenSshCertificate;
-import static java.nio.charset.StandardCharsets.UTF_8;
-
public class JSch {
/** The version number. */
public static final String VERSION = Version.getVersion();
@@ -264,6 +260,9 @@ public class JSch {
config.put("MaxAuthTries", Util.getSystemProperty("jsch.max_auth_tries", "6"));
config.put("ClearAllForwardings", "no");
+ config.put("ClearAllKeys", "no");
+ config.put("HostCertificateToKeyFallback",
+ Util.getSystemProperty("jsch.host_certificate_to_key_fallback", "no"));
}
final InstanceLogger instLogger = new InstanceLogger();
@@ -497,18 +496,18 @@ public void addIdentity(String prvkey, byte[] passphrase) throws JSchException {
* @throws JSchException if passphrase is not right.
*/
public void addIdentity(String prvkey, String pubkey, byte[] passphrase) throws JSchException {
- String pubkeyFileContent = null;
+ byte[] pubkeyFileContent = null;
Identity identity;
if (pubkey != null) {
try {
- pubkeyFileContent = new String(Util.fromFile(pubkey), UTF_8);
+ pubkeyFileContent = Util.fromFile(pubkey);
} catch (IOException e) {
throw new JSchException(e.toString(), e);
}
}
-
- if (pubkeyFileContent != null && isOpenSshCertificate(pubkeyFileContent)) {
+ if (pubkeyFileContent != null
+ && OpenSshCertificateAwareIdentityFile.isOpenSshCertificateFile(pubkeyFileContent)) {
identity = OpenSshCertificateAwareIdentityFile.newInstance(prvkey, pubkey, instLogger);
} else {
identity = IdentityFile.newInstance(prvkey, pubkey, instLogger);
@@ -528,10 +527,8 @@ public void addIdentity(String prvkey, String pubkey, byte[] passphrase) throws
*/
public void addIdentity(String name, byte[] prvkey, byte[] pubkey, byte[] passphrase)
throws JSchException {
- String pubkeyFileContent = new String(pubkey, UTF_8);
Identity identity;
-
- if (isOpenSshCertificate(pubkeyFileContent)) {
+ if (OpenSshCertificateAwareIdentityFile.isOpenSshCertificateFile(pubkey)) {
identity = OpenSshCertificateAwareIdentityFile.newInstance(name, prvkey, pubkey, instLogger);
} else {
identity = IdentityFile.newInstance(name, prvkey, pubkey, instLogger);
diff --git a/src/main/java/com/jcraft/jsch/KeyExchange.java b/src/main/java/com/jcraft/jsch/KeyExchange.java
index 5a89e450d..3fdc222fa 100644
--- a/src/main/java/com/jcraft/jsch/KeyExchange.java
+++ b/src/main/java/com/jcraft/jsch/KeyExchange.java
@@ -26,10 +26,8 @@
package com.jcraft.jsch;
-import java.nio.charset.StandardCharsets;
import java.util.Locale;
-
public abstract class KeyExchange {
static final int PROPOSAL_KEX_ALGS = 0;
@@ -73,11 +71,11 @@ public abstract class KeyExchange {
protected OpenSshCertificate hostKeyCertificate = null;
protected boolean isOpenSshServerHostKeyType = false;
- public OpenSshCertificate getHostKeyCertificate() {
+ OpenSshCertificate getHostKeyCertificate() {
return hostKeyCertificate;
}
- public boolean isOpenSshServerHostKeyType() {
+ boolean isOpenSshServerHostKeyType() {
return isOpenSshServerHostKeyType;
}
@@ -233,6 +231,10 @@ protected static String[] guess(Session session, byte[] I_S, byte[] I_C) throws
}
public String getFingerPrint() {
+ return getFingerPrint(getHostKey());
+ }
+
+ public String getFingerPrint(byte[] key) {
HASH hash = null;
try {
String _c = session.getConfig("FingerprintHash").toLowerCase(Locale.ROOT);
@@ -243,7 +245,7 @@ public String getFingerPrint() {
session.getLogger().log(Logger.ERROR, "getFingerPrint: " + e.getMessage(), e);
}
}
- return Util.getFingerPrint(hash, getHostKey(), true, false);
+ return Util.getFingerPrint(hash, key, true, false);
}
byte[] getK() {
@@ -326,13 +328,92 @@ protected byte[] normalize(byte[] secret) {
}
- protected boolean verifyKeyExchangeServerSignature(String alg, byte[] K_S, int index,
- byte[] sig_of_H) throws Exception {
+ /**
+ * Verifies the cryptographic signature of the SSH key exchange hash.
+ *
+ * This method performs cryptographic verification that the remote server possesses the private
+ * key corresponding to the public key presented during the SSH key exchange. It supports both
+ * traditional SSH public keys and OpenSSH certificates.
+ *
+ *
+ * Public Key vs. Certificate Handling
+ *
+ * The method handles two distinct input formats:
+ *
+ *
+ * - Plain Public Keys: When {@code alg} is a standard key algorithm (e.g.,
+ * {@code "ssh-rsa"}, {@code "ssh-ed25519"}), the {@code K_S} parameter contains the server's
+ * public key in SSH wire format. The method parses the key components and verifies the signature
+ * directly.
+ *
+ * - OpenSSH Certificates: When {@code alg} is a certificate type (e.g.,
+ * {@code "ssh-rsa-cert-v01@openssh.com"}), the {@code K_S} parameter contains an OpenSSH
+ * certificate structure. The method:
+ *
+ * - Parses the certificate to extract the embedded public key
+ * - Validates that the certificate is a host certificate (not a user certificate)
+ * - Replaces {@code K_S} with the extracted public key
+ * - Extracts the underlying algorithm name from the public key
+ * - Stores the certificate for subsequent CA validation
+ * - Proceeds with signature verification using the extracted public key
+ *
+ *
+ *
+ *
+ * Two-Stage Verification for Certificates
+ *
+ * For OpenSSH certificates, this method performs only the first stage of verification:
+ * proving that the server possesses the private key corresponding to the public key embedded in
+ * the certificate. The second stage (validating the certificate's CA signature, validity
+ * period, principals, and other certificate-specific properties) is performed separately by
+ * {@link OpenSshCertificateHostKeyVerifier#checkHostCertificate(Session, OpenSshCertificate)}.
+ *
+ *
+ * Signature Verification Process
+ *
+ * After extracting the public key (either from the plain input or from within a certificate), the
+ * method:
+ *
+ *
+ * - Determines the key algorithm (RSA, DSS, ECDSA, or EdDSA)
+ * - Parses the algorithm-specific public key components from the SSH wire format
+ * - Instantiates the appropriate signature verification class
+ * - Verifies that {@code sig_of_H} is a valid signature of the exchange hash {@code H} using
+ * the public key
+ *
+ *
+ * @param alg the server host key algorithm name. This can be either a plain key algorithm (e.g.,
+ * {@code "ssh-rsa"}, {@code "ssh-dss"}, {@code "ecdsa-sha2-nistp256"},
+ * {@code "ssh-ed25519"}) or a certificate type (e.g.,
+ * {@code "ssh-rsa-cert-v01@openssh.com"}, {@code "ssh-ed25519-cert-v01@openssh.com"}). For
+ * certificates, this parameter is internally replaced with the underlying key algorithm
+ * extracted from the certificate.
+ * @param K_S the server's public key blob in SSH wire format. For plain keys, this contains the
+ * public key directly. For certificates, this contains the complete OpenSSH certificate
+ * structure, which includes the public key along with additional metadata (CA signature,
+ * principals, validity period, etc.). When a certificate is detected, this reference is
+ * replaced internally with the extracted public key for verification purposes.
+ * @param index the starting byte offset within {@code K_S} from which to begin parsing. For plain
+ * keys, this is typically the position after the algorithm string. For certificates, this
+ * is typically {@code 0} (start of the certificate blob), and the offset is recalculated
+ * after extracting the embedded public key.
+ * @param sig_of_H the signature bytes to verify. This is the server's signature of the exchange
+ * hash {@code H}, which proves the server possesses the private key. The signature format
+ * is algorithm-specific and includes both the algorithm identifier and the actual
+ * signature data in SSH wire format.
+ * @return {@code true} if the signature is cryptographically valid and proves the server
+ * possesses the private key; {@code false} otherwise.
+ * @throws JSchException if the algorithm is unsupported, if a certificate is detected but is not
+ * a host certificate (e.g., it's a user certificate), if the signature verification class
+ * cannot be instantiated, or if any other error occurs during verification.
+ * @throws Exception if an unexpected error occurs during parsing or cryptographic operations.
+ */
+ protected boolean verify(String alg, byte[] K_S, int index, byte[] sig_of_H) throws Exception {
int i, j;
+ boolean result = false;
OpenSshCertificateBuffer buffer = new OpenSshCertificateBuffer(K_S);
buffer.s = index;
- boolean result = false;
- i = 0;
+ i = index;
if (OpenSshCertificateAwareIdentityFile.isOpenSshCertificateKeyType(alg)) {
this.isOpenSshServerHostKeyType = true;
@@ -342,26 +423,44 @@ protected boolean verifyKeyExchangeServerSignature(String alg, byte[] K_S, int i
// SSH2_CERT_TYPE_HOST.
// Other certificate types MUST not be accepted.
if (!certificate.isHostCertificate()) {
- throw new JSchException(
- "Server host key is not a valid SSH certificate of type SSH2_CERT_TYPE_HOST.");
+ throw new JSchInvalidHostCertificateException("Rejected certificate '" + certificate.getId()
+ + "': user certificate presented for host authentication. " + "Host: " + session.host);
}
- byte[] serverPublicKeyByteArray = certificate.getCertificatePublicKey();
- buffer = new OpenSshCertificateBuffer(serverPublicKeyByteArray);
- alg = buffer.getString(StandardCharsets.UTF_8);
+ K_S = certificate.getCertificatePublicKey();
+
+ // Extract algorithm from certificate public key
+ i = 0;
+ j = 0;
+ j = ((K_S[i++] << 24) & 0xff000000) | ((K_S[i++] << 16) & 0x00ff0000)
+ | ((K_S[i++] << 8) & 0x0000ff00) | ((K_S[i++]) & 0x000000ff);
+ alg = Util.byte2str(K_S, i, j);
+ i += j;
+
this.hostKeyCertificate = certificate;
}
- if ("ssh-rsa".equals(alg)) {
+ if (alg.equals("ssh-rsa")) {
+ byte[] tmp;
byte[] ee;
byte[] n;
- this.type = RSA;
+ type = RSA;
key_alg_name = alg;
- ee = buffer.getMPInt();
- n = buffer.getMPInt();
-
- SignatureRSA sig;
+ j = ((K_S[i++] << 24) & 0xff000000) | ((K_S[i++] << 16) & 0x00ff0000)
+ | ((K_S[i++] << 8) & 0x0000ff00) | ((K_S[i++]) & 0x000000ff);
+ tmp = new byte[j];
+ System.arraycopy(K_S, i, tmp, 0, j);
+ i += j;
+ ee = tmp;
+ j = ((K_S[i++] << 24) & 0xff000000) | ((K_S[i++] << 16) & 0x00ff0000)
+ | ((K_S[i++] << 8) & 0x0000ff00) | ((K_S[i++]) & 0x000000ff);
+ tmp = new byte[j];
+ System.arraycopy(K_S, i, tmp, 0, j);
+ i += j;
+ n = tmp;
+
+ SignatureRSA sig = null;
Buffer buf = new Buffer(sig_of_H);
String foo = Util.byte2str(buf.getString());
try {
@@ -379,96 +478,131 @@ protected boolean verifyKeyExchangeServerSignature(String alg, byte[] K_S, int i
if (session.getLogger().isEnabled(Logger.INFO)) {
session.getLogger().log(Logger.INFO, "ssh_rsa_verify: " + foo + " signature " + result);
}
- } else if ("ssh-dss".equals(alg)) {
- byte[] q;
+ } else if (alg.equals("ssh-dss")) {
+ byte[] q = null;
+ byte[] tmp;
byte[] p;
byte[] g;
- byte[] y;
+ byte[] f;
type = DSS;
key_alg_name = alg;
- p = buffer.getMPInt();
- q = buffer.getMPInt();
- g = buffer.getMPInt();
- y = buffer.getMPInt();
-
- SignatureDSA sigDSA;
+ j = ((K_S[i++] << 24) & 0xff000000) | ((K_S[i++] << 16) & 0x00ff0000)
+ | ((K_S[i++] << 8) & 0x0000ff00) | ((K_S[i++]) & 0x000000ff);
+ tmp = new byte[j];
+ System.arraycopy(K_S, i, tmp, 0, j);
+ i += j;
+ p = tmp;
+ j = ((K_S[i++] << 24) & 0xff000000) | ((K_S[i++] << 16) & 0x00ff0000)
+ | ((K_S[i++] << 8) & 0x0000ff00) | ((K_S[i++]) & 0x000000ff);
+ tmp = new byte[j];
+ System.arraycopy(K_S, i, tmp, 0, j);
+ i += j;
+ q = tmp;
+ j = ((K_S[i++] << 24) & 0xff000000) | ((K_S[i++] << 16) & 0x00ff0000)
+ | ((K_S[i++] << 8) & 0x0000ff00) | ((K_S[i++]) & 0x000000ff);
+ tmp = new byte[j];
+ System.arraycopy(K_S, i, tmp, 0, j);
+ i += j;
+ g = tmp;
+ j = ((K_S[i++] << 24) & 0xff000000) | ((K_S[i++] << 16) & 0x00ff0000)
+ | ((K_S[i++] << 8) & 0x0000ff00) | ((K_S[i++]) & 0x000000ff);
+ tmp = new byte[j];
+ System.arraycopy(K_S, i, tmp, 0, j);
+ i += j;
+ f = tmp;
+
+ SignatureDSA sig = null;
try {
Class extends SignatureDSA> c =
Class.forName(session.getConfig("signature.dss")).asSubclass(SignatureDSA.class);
- sigDSA = c.getDeclaredConstructor().newInstance();
- sigDSA.init();
+ sig = c.getDeclaredConstructor().newInstance();
+ sig.init();
} catch (Exception e) {
throw new JSchException(e.toString(), e);
}
- sigDSA.setPubKey(y, p, q, g);
- sigDSA.update(H);
- result = sigDSA.verify(sig_of_H);
+ sig.setPubKey(f, p, q, g);
+ sig.update(H);
+ result = sig.verify(sig_of_H);
if (session.getLogger().isEnabled(Logger.INFO)) {
session.getLogger().log(Logger.INFO, "ssh_dss_verify: signature " + result);
}
- } else if ("ecdsa-sha2-nistp256".equals(alg) || "ecdsa-sha2-nistp384".equals(alg)
- || "ecdsa-sha2-nistp521".equals(alg)) {
+ } else if (alg.equals("ecdsa-sha2-nistp256") || alg.equals("ecdsa-sha2-nistp384")
+ || alg.equals("ecdsa-sha2-nistp521")) {
+ byte[] tmp;
byte[] r;
byte[] s;
// RFC 5656,
type = ECDSA;
- this.key_alg_name = alg;
-
- // https://www.rfc-editor.org/rfc/rfc5656#section-3.1
- // The string [identifier] is the identifier of the elliptic curve domain parameters.
- String identifier = Util.byte2str(buffer.getString());
-
- int len = buffer.getInt();
- int x04 = buffer.getByte();
- r = new byte[(len - 1) / 2];
- s = new byte[(len - 1) / 2];
- buffer.getByte(r);
- buffer.getByte(s);
+ key_alg_name = alg;
- SignatureECDSA sigECDSA = null;
+ j = ((K_S[i++] << 24) & 0xff000000) | ((K_S[i++] << 16) & 0x00ff0000)
+ | ((K_S[i++] << 8) & 0x0000ff00) | ((K_S[i++]) & 0x000000ff);
+ tmp = new byte[j];
+ System.arraycopy(K_S, i, tmp, 0, j);
+ i += j;
+ j = ((K_S[i++] << 24) & 0xff000000) | ((K_S[i++] << 16) & 0x00ff0000)
+ | ((K_S[i++] << 8) & 0x0000ff00) | ((K_S[i++]) & 0x000000ff);
+ i++;
+ tmp = new byte[(j - 1) / 2];
+ System.arraycopy(K_S, i, tmp, 0, tmp.length);
+ i += (j - 1) / 2;
+ r = tmp;
+ tmp = new byte[(j - 1) / 2];
+ System.arraycopy(K_S, i, tmp, 0, tmp.length);
+ i += (j - 1) / 2;
+ s = tmp;
+
+ SignatureECDSA sig = null;
try {
Class extends SignatureECDSA> c =
Class.forName(session.getConfig(alg)).asSubclass(SignatureECDSA.class);
- sigECDSA = c.getDeclaredConstructor().newInstance();
- sigECDSA.init();
+ sig = c.getDeclaredConstructor().newInstance();
+ sig.init();
} catch (Exception e) {
throw new JSchException(e.toString(), e);
}
- sigECDSA.setPubKey(r, s);
- sigECDSA.update(H);
- result = sigECDSA.verify(sig_of_H);
+ sig.setPubKey(r, s);
+
+ sig.update(H);
+
+ result = sig.verify(sig_of_H);
if (session.getLogger().isEnabled(Logger.INFO)) {
session.getLogger().log(Logger.INFO, "ssh_ecdsa_verify: " + alg + " signature " + result);
}
- } else if ("ssh-ed25519".equals(alg) || "ssh-ed448".equals(alg)) {
+ } else if (alg.equals("ssh-ed25519") || alg.equals("ssh-ed448")) {
+ byte[] tmp;
+
// RFC 8709,
type = EDDSA;
key_alg_name = alg;
- int keyLength = buffer.getInt();
- byte[] edXXX_pub_array = new byte[keyLength];
- buffer.getByte(edXXX_pub_array);
- SignatureEdDSA sigEdDSA = null;
+ j = ((K_S[i++] << 24) & 0xff000000) | ((K_S[i++] << 16) & 0x00ff0000)
+ | ((K_S[i++] << 8) & 0x0000ff00) | ((K_S[i++]) & 0x000000ff);
+ tmp = new byte[j];
+ System.arraycopy(K_S, i, tmp, 0, j);
+ i += j;
+
+ SignatureEdDSA sig = null;
try {
Class extends SignatureEdDSA> c =
Class.forName(session.getConfig(alg)).asSubclass(SignatureEdDSA.class);
- sigEdDSA = c.getDeclaredConstructor().newInstance();
- sigEdDSA.init();
+ sig = c.getDeclaredConstructor().newInstance();
+ sig.init();
} catch (Exception | LinkageError e) {
throw new JSchException(e.toString(), e);
}
- sigEdDSA.setPubKey(edXXX_pub_array);
+ sig.setPubKey(tmp);
- sigEdDSA.update(H);
+ sig.update(H);
- result = sigEdDSA.verify(sig_of_H);
+ result = sig.verify(sig_of_H);
if (session.getLogger().isEnabled(Logger.INFO)) {
session.getLogger().log(Logger.INFO, "ssh_eddsa_verify: " + alg + " signature " + result);
@@ -478,8 +612,8 @@ protected boolean verifyKeyExchangeServerSignature(String alg, byte[] K_S, int i
session.getLogger().log(Logger.ERROR, "unknown alg: " + alg);
}
}
- return result;
+ return result;
}
protected byte[] encodeInt(int raw) {
diff --git a/src/main/java/com/jcraft/jsch/OpenSshCertificate.java b/src/main/java/com/jcraft/jsch/OpenSshCertificate.java
index 0f08f6a6e..cfac5d59b 100644
--- a/src/main/java/com/jcraft/jsch/OpenSshCertificate.java
+++ b/src/main/java/com/jcraft/jsch/OpenSshCertificate.java
@@ -9,8 +9,8 @@
*
*
* OpenSSH certificates are a mechanism for providing cryptographic proof of authorization to access
- * SSH resources. They consist of a public key along with identity information and usage
- * restrictions that have been signed by a certificate authority (CA).
+ * SSH resources. They consist of a key along with identity information and usage restrictions that
+ * have been signed by a certificate authority (CA).
*
*
*
@@ -26,22 +26,22 @@ class OpenSshCertificate {
/**
* Certificate type constant for user certificates
*/
- public static final int SSH2_CERT_TYPE_USER = 1;
+ static final int SSH2_CERT_TYPE_USER = 1;
/**
* Certificate type constant for user certificates
*/
- public static final int SSH2_CERT_TYPE_HOST = 2;
+ static final int SSH2_CERT_TYPE_HOST = 2;
/**
* Minimum validity period (epoch start)
*/
- public static final long MIN_VALIDITY = 0L;
+ static final long MIN_VALIDITY = 0L;
/**
* Maximum validity period (maximum unsigned 64-bit value)
*/
- public static final long MAX_VALIDITY = 0xffff_ffff_ffff_ffffL;
+ static final long MAX_VALIDITY = 0xffff_ffff_ffff_ffffL;
/**
* The certificate key type (e.g., "ssh-rsa-cert-v01@openssh.com")
@@ -54,7 +54,7 @@ class OpenSshCertificate {
private final byte[] nonce;
/**
- * The certificate's public key in SSH wire format
+ * The certificate's key in SSH wire format
*/
private final byte[] certificatePublicKey;
@@ -100,7 +100,7 @@ class OpenSshCertificate {
private final String reserved;
/**
- * The CA's public key that signed this certificate
+ * The CA's key that signed this certificate
*/
private final byte[] signatureKey;
@@ -135,82 +135,82 @@ private OpenSshCertificate(Builder builder) {
this.message = builder.message;
}
- public String getKeyType() {
+ String getKeyType() {
return keyType;
}
- public byte[] getNonce() {
+ byte[] getNonce() {
return nonce;
}
- public byte[] getCertificatePublicKey() {
+ byte[] getCertificatePublicKey() {
return certificatePublicKey;
}
- public long getSerial() {
+ long getSerial() {
return serial;
}
- public int getType() {
+ int getType() {
return type;
}
- public String getId() {
+ String getId() {
return id;
}
- public Collection getPrincipals() {
+ Collection getPrincipals() {
return principals;
}
- public long getValidAfter() {
+ long getValidAfter() {
return validAfter;
}
- public long getValidBefore() {
+ long getValidBefore() {
return validBefore;
}
- public Map getCriticalOptions() {
+ Map getCriticalOptions() {
return criticalOptions;
}
- public Map getExtensions() {
+ Map getExtensions() {
return extensions;
}
- public String getReserved() {
+ String getReserved() {
return reserved;
}
- public byte[] getSignatureKey() {
+ byte[] getSignatureKey() {
return signatureKey;
}
- public byte[] getSignature() {
+ byte[] getSignature() {
return signature;
}
- public boolean isUserCertificate() {
+ boolean isUserCertificate() {
return SSH2_CERT_TYPE_USER == type;
}
- public boolean isHostCertificate() {
+ boolean isHostCertificate() {
return SSH2_CERT_TYPE_HOST == type;
}
- public boolean isValidNow() {
+ boolean isValidNow() {
return OpenSshCertificateUtil.isValidNow(this);
}
- public byte[] getMessage() {
+ byte[] getMessage() {
return message;
}
/**
* A static inner builder class for creating immutable OpenSshCertificate instances.
*/
- public static class Builder {
+ static class Builder {
private String keyType;
private byte[] nonce;
private byte[] certificatePublicKey;
@@ -227,79 +227,79 @@ public static class Builder {
private byte[] signature;
private byte[] message;
- public Builder() {}
+ Builder() {}
- public Builder keyType(String keyType) {
+ Builder keyType(String keyType) {
this.keyType = keyType;
return this;
}
- public Builder nonce(byte[] nonce) {
+ Builder nonce(byte[] nonce) {
this.nonce = nonce;
return this;
}
- public Builder certificatePublicKey(byte[] pk) {
+ Builder certificatePublicKey(byte[] pk) {
this.certificatePublicKey = pk;
return this;
}
- public Builder serial(long serial) {
+ Builder serial(long serial) {
this.serial = serial;
return this;
}
- public Builder type(int type) {
+ Builder type(int type) {
this.type = type;
return this;
}
- public Builder id(String id) {
+ Builder id(String id) {
this.id = id;
return this;
}
- public Builder principals(Collection principals) {
+ Builder principals(Collection principals) {
this.principals = principals;
return this;
}
- public Builder validAfter(long validAfter) {
+ Builder validAfter(long validAfter) {
this.validAfter = validAfter;
return this;
}
- public Builder validBefore(long validBefore) {
+ Builder validBefore(long validBefore) {
this.validBefore = validBefore;
return this;
}
- public Builder criticalOptions(Map opts) {
+ Builder criticalOptions(Map opts) {
this.criticalOptions = opts;
return this;
}
- public Builder extensions(Map exts) {
+ Builder extensions(Map exts) {
this.extensions = exts;
return this;
}
- public Builder reserved(String reserved) {
+ Builder reserved(String reserved) {
this.reserved = reserved;
return this;
}
- public Builder signatureKey(byte[] sigKey) {
+ Builder signatureKey(byte[] sigKey) {
this.signatureKey = sigKey;
return this;
}
- public Builder signature(byte[] signature) {
+ Builder signature(byte[] signature) {
this.signature = signature;
return this;
}
- public Builder message(byte[] message) {
+ Builder message(byte[] message) {
this.message = message;
return this;
}
@@ -309,7 +309,7 @@ public Builder message(byte[] message) {
*
* @return A new, immutable OpenSshCertificate object.
*/
- public OpenSshCertificate build() {
+ OpenSshCertificate build() {
// You could add validation logic here if needed (e.g., check for null required fields)
return new OpenSshCertificate(this);
}
diff --git a/src/main/java/com/jcraft/jsch/OpenSshCertificateAwareIdentityFile.java b/src/main/java/com/jcraft/jsch/OpenSshCertificateAwareIdentityFile.java
index dde6a2d40..d2f8d6a80 100644
--- a/src/main/java/com/jcraft/jsch/OpenSshCertificateAwareIdentityFile.java
+++ b/src/main/java/com/jcraft/jsch/OpenSshCertificateAwareIdentityFile.java
@@ -1,17 +1,7 @@
package com.jcraft.jsch;
import java.io.IOException;
-import java.security.NoSuchAlgorithmException;
-import java.util.Base64;
-
-import static com.jcraft.jsch.OpenSshCertificateUtil.extractComment;
-import static com.jcraft.jsch.OpenSshCertificateUtil.extractKeyData;
-import static com.jcraft.jsch.OpenSshCertificateUtil.extractKeyType;
-import static com.jcraft.jsch.OpenSshCertificateUtil.getRawKeyType;
-import static com.jcraft.jsch.OpenSshCertificateUtil.toDateString;
-import static com.jcraft.jsch.OpenSshCertificateUtil.trimToEmptyIfNull;
-import static com.jcraft.jsch.Util.fromBase64;
-import static java.nio.charset.StandardCharsets.UTF_8;
+import java.nio.charset.StandardCharsets;
/**
* An {@link Identity} implementation that supports OpenSSH certificates.
@@ -29,75 +19,22 @@
*/
class OpenSshCertificateAwareIdentityFile implements Identity {
- public static final String SSH_RSA_CERT_V01_AT_OPENSSH_DOT_COM = "ssh-rsa-cert-v01@openssh.com";
- public static final String SSH_RSA_CERT = "ssh-rsa-cert";
+ static final String SSH_RSA_CERT_V01_AT_OPENSSH_DOT_COM = "ssh-rsa-cert-v01@openssh.com";
- public static final String SSH_DSS_CERT_V01_AT_OPENSSH_DOT_COM = "ssh-dss-cert-v01@openssh.com";
- public static final String SSH_DSS_CERT = "ssh-dss-cert";
+ static final String SSH_DSS_CERT_V01_AT_OPENSSH_DOT_COM = "ssh-dss-cert-v01@openssh.com";
- public static final String ECDSA_SHA2_NISTP256_CERT_V01_AT_OPENSSH_DOT_COM =
+ static final String ECDSA_SHA2_NISTP256_CERT_V01_AT_OPENSSH_DOT_COM =
"ecdsa-sha2-nistp256-cert-v01@openssh.com";
- public static final String ECDSA_SHA2_NISTP256_CERT = "ecdsa-sha2-nistp256-cert";
- public static final String ECDSA_SHA2_NISTP384_CERT_V01_AT_OPENSSH_DOT_COM =
+ static final String ECDSA_SHA2_NISTP384_CERT_V01_AT_OPENSSH_DOT_COM =
"ecdsa-sha2-nistp384-cert-v01@openssh.com";
- public static final String ECDSA_SHA2_NISTP384_CERT = "ecdsa-sha2-nistp384-cert";
- public static final String ECDSA_SHA2_NISTP521_CERT_V01_AT_OPENSSH_DOT_COM =
+ static final String ECDSA_SHA2_NISTP521_CERT_V01_AT_OPENSSH_DOT_COM =
"ecdsa-sha2-nistp521-cert-v01@openssh.com";
- public static final String ECDSA_SHA2_NISTP521_CERT = "ecdsa-sha2-nistp521-cert";
+ static final String SSH_ED25519_CERT_V01_AT_OPENSSH_DOT_COM = "ssh-ed25519-cert-v01@openssh.com";
- public static final String SSH_ED25519_CERT_V01_AT_OPENSSH_DOT_COM =
- "ssh-ed25519-cert-v01@openssh.com";
- public static final String SSH_ED25519_CERT = "ssh-ed25519-cert";
-
- public static final String SSH_ED448_CERT_V01_AT_OPENSSH_DOT_COM =
- "ssh-ed448-cert-v01@openssh.com";
- public static final String SSH_ED448_CERT = "ssh-ed448-cert";
-
-
- /**
- * Determines if the given certificate file content as String, represents an OpenSSH certificate.
- *
- * @param certificateFileContent the certificate string to check
- * @return {@code true} if the file content is a supported OpenSSH certificate type, {@code false}
- * otherwise
- */
- public static boolean isOpenSshCertificate(String certificateFileContent) {
- String certificateKeyType = extractKeyType(trimToEmptyIfNull(certificateFileContent));
- return isOpenSshCertificateKeyType(certificateKeyType);
- }
-
-
- /**
- * Determines if the given public key type represents an OpenSSH certificate.
- *
- * @param publicKeyType the public key type string to check
- * @return {@code true} if the type is a supported OpenSSH certificate type, {@code false}
- * otherwise
- */
- public static boolean isOpenSshCertificateKeyType(String publicKeyType) {
- switch (publicKeyType) {
- case SSH_RSA_CERT_V01_AT_OPENSSH_DOT_COM:
- case SSH_DSS_CERT_V01_AT_OPENSSH_DOT_COM:
- case ECDSA_SHA2_NISTP256_CERT_V01_AT_OPENSSH_DOT_COM:
- case ECDSA_SHA2_NISTP384_CERT_V01_AT_OPENSSH_DOT_COM:
- case ECDSA_SHA2_NISTP521_CERT_V01_AT_OPENSSH_DOT_COM:
- case SSH_ED25519_CERT_V01_AT_OPENSSH_DOT_COM:
- case SSH_ED448_CERT_V01_AT_OPENSSH_DOT_COM:
- case SSH_RSA_CERT:
- case SSH_DSS_CERT:
- case ECDSA_SHA2_NISTP256_CERT:
- case ECDSA_SHA2_NISTP384_CERT:
- case ECDSA_SHA2_NISTP521_CERT:
- case SSH_ED25519_CERT:
- case SSH_ED448_CERT:
- return true;
- default:
- return false;
- }
- }
+ static final String SSH_ED448_CERT_V01_AT_OPENSSH_DOT_COM = "ssh-ed448-cert-v01@openssh.com";
/**
* parsed certificate.
@@ -129,6 +66,64 @@ public static boolean isOpenSshCertificateKeyType(String publicKeyType) {
*/
private final String comment;
+ /**
+ * Determines if the given certificate file content as byte array, represents an OpenSSH
+ * certificate.
+ *
+ * @param certificateFileContent the certificate bytes to check
+ * @return {@code true} if the file content is a supported OpenSSH certificate type, {@code false}
+ * otherwise
+ */
+ static boolean isOpenSshCertificateFile(byte[] certificateFileContent) {
+ if (certificateFileContent == null || certificateFileContent.length == 0) {
+ return false;
+ }
+
+ byte[] keyTypeBytes =
+ OpenSshCertificateUtil.extractSpaceDelimitedString(certificateFileContent, 0);
+
+ // avoid converting byte array to string if the keyType is clearly not a supported certificate
+ if (keyTypeBytes == null || keyTypeBytes.length == 0 || keyTypeBytes.length > 100) {
+ return false;
+ }
+
+ String keyType = new String(keyTypeBytes, StandardCharsets.UTF_8);
+
+ return isOpenSshCertificateKeyType(keyType);
+
+ /*
+ * switch(keyType){ case SSH_RSA_CERT_V01_AT_OPENSSH_DOT_COM: case
+ * SSH_DSS_CERT_V01_AT_OPENSSH_DOT_COM: case ECDSA_SHA2_NISTP256_CERT_V01_AT_OPENSSH_DOT_COM:
+ * case ECDSA_SHA2_NISTP384_CERT_V01_AT_OPENSSH_DOT_COM: case
+ * ECDSA_SHA2_NISTP521_CERT_V01_AT_OPENSSH_DOT_COM: case
+ * SSH_ED25519_CERT_V01_AT_OPENSSH_DOT_COM: case SSH_ED448_CERT_V01_AT_OPENSSH_DOT_COM: return
+ * true; default: return false; }
+ */
+ }
+
+
+
+ /**
+ * Determines if the given key type represents an OpenSSH certificate.
+ *
+ * @param publicKeyType the key type string to check
+ * @return {@code true} if the type is a supported OpenSSH certificate type, {@code false}
+ * otherwise
+ */
+ static boolean isOpenSshCertificateKeyType(String publicKeyType) {
+ switch (publicKeyType) {
+ case SSH_RSA_CERT_V01_AT_OPENSSH_DOT_COM:
+ case SSH_DSS_CERT_V01_AT_OPENSSH_DOT_COM:
+ case ECDSA_SHA2_NISTP256_CERT_V01_AT_OPENSSH_DOT_COM:
+ case ECDSA_SHA2_NISTP384_CERT_V01_AT_OPENSSH_DOT_COM:
+ case ECDSA_SHA2_NISTP521_CERT_V01_AT_OPENSSH_DOT_COM:
+ case SSH_ED25519_CERT_V01_AT_OPENSSH_DOT_COM:
+ case SSH_ED448_CERT_V01_AT_OPENSSH_DOT_COM:
+ return true;
+ default:
+ return false;
+ }
+ }
/**
* Creates a new certificate-aware identity from file paths.
@@ -165,45 +160,47 @@ static Identity newInstance(String prvfile, String pubfile, JSch.InstanceLogger
*/
static Identity newInstance(String name, byte[] prvkey, byte[] certificateFileContentBytes,
JSch.InstanceLogger instLogger) throws JSchException {
- String certificateFileContentString = new String(certificateFileContentBytes, UTF_8);
OpenSshCertificate cert;
byte[] certPublicKey;
KeyPair kpair;
+ byte[] declaredKeyTypeBytes;
+ byte[] commentBytes;
+ byte[] base64KeyDataBytes;
String declaredKeyType;
String comment;
- String base64KeyData;
-
try {
- declaredKeyType = extractKeyType(certificateFileContentString);
- base64KeyData = extractKeyData(certificateFileContentString);
- comment = extractComment(certificateFileContentString);
- byte[] keyData =
- fromBase64(base64KeyData.getBytes(UTF_8), 0, base64KeyData.getBytes(UTF_8).length);
+ declaredKeyTypeBytes = OpenSshCertificateUtil.extractKeyType(certificateFileContentBytes);
+ base64KeyDataBytes = OpenSshCertificateUtil.extractKeyData(certificateFileContentBytes);
+ commentBytes = OpenSshCertificateUtil.extractComment(certificateFileContentBytes);
+ byte[] keyData = Util.fromBase64(base64KeyDataBytes, 0, base64KeyDataBytes.length);
cert = OpenSshCertificateParser.parse(instLogger, keyData);
+ declaredKeyType = Util.byte2str(declaredKeyTypeBytes, StandardCharsets.UTF_8);
+ comment = Util.byte2str(commentBytes, StandardCharsets.UTF_8);
+
// keyType
- if (cert.getKeyType().isEmpty() || !cert.getKeyType().equals(declaredKeyType)) {
+ if (OpenSshCertificateUtil.isEmpty(cert.getKeyType())
+ || !cert.getKeyType().equals(declaredKeyType)) {
instLogger.getLogger().log(Logger.WARN,
"Key type declared at the beginning of the certificate file, does not correspond to the encoded key type. Declared type: '"
- + declaredKeyType + "' - Encoded Key type: '" + base64KeyData + "'"
- + cert.getKeyType() + "'");
+ + declaredKeyType + "' - Encoded Key type: '" + cert.getKeyType() + "'");
}
if (!cert.isValidNow()) {
instLogger.getLogger().log(Logger.WARN,
- "certificate is not valid. Valid after: " + toDateString(cert.getValidAfter())
- + " - Valid before: " + toDateString(cert.getValidBefore()));
+ "certificate is not valid. Valid after: "
+ + OpenSshCertificateUtil.toDateString(cert.getValidAfter()) + " - Valid before: "
+ + OpenSshCertificateUtil.toDateString(cert.getValidBefore()));
}
certPublicKey = cert.getCertificatePublicKey();
kpair = KeyPair.load(instLogger, prvkey, certPublicKey);
-
} catch (Exception e) {
throw new JSchException(e.toString(), e);
}
- return new OpenSshCertificateAwareIdentityFile(name, declaredKeyType, base64KeyData, cert,
+ return new OpenSshCertificateAwareIdentityFile(name, declaredKeyType, base64KeyDataBytes, cert,
kpair, comment);
}
@@ -216,14 +213,14 @@ static Identity newInstance(String name, byte[] prvkey, byte[] certificateFileCo
* @param kpair the key pair containing the private key
* @param comment the optional comment from the certificate file
*/
- private OpenSshCertificateAwareIdentityFile(String name, String keyType, String base64KeyData,
- OpenSshCertificate certificate, KeyPair kpair, String comment) {
+ private OpenSshCertificateAwareIdentityFile(String name, String keyType, byte[] base64KeyData,
+ OpenSshCertificate certificate, KeyPair kpair, String comment) throws JSchException {
this.identity = name;
this.certificate = certificate;
this.kpair = kpair;
this.comment = comment;
this.keyType = keyType;
- this.publicKeyBlob = Base64.getDecoder().decode(base64KeyData);
+ this.publicKeyBlob = Util.fromBase64(base64KeyData, 0, base64KeyData.length);
}
@Override
@@ -243,7 +240,7 @@ public byte[] getSignature(byte[] data) {
@Override
public byte[] getSignature(byte[] data, String alg) {
- String rawKeyType = getRawKeyType(keyType);
+ String rawKeyType = OpenSshCertificateUtil.getRawKeyType(keyType);
return kpair.getSignature(data, rawKeyType);
}
@@ -267,19 +264,19 @@ public void clear() {
kpair.dispose();
}
- public String getKeyType() {
+ String getKeyType() {
return keyType;
}
- public KeyPair getKpair() {
+ KeyPair getKpair() {
return kpair;
}
- public String getIdentity() {
+ String getIdentity() {
return identity;
}
- public String getComment() {
+ String getComment() {
return comment;
}
}
diff --git a/src/main/java/com/jcraft/jsch/OpenSshCertificateBuffer.java b/src/main/java/com/jcraft/jsch/OpenSshCertificateBuffer.java
index 463a97610..fd2536b94 100644
--- a/src/main/java/com/jcraft/jsch/OpenSshCertificateBuffer.java
+++ b/src/main/java/com/jcraft/jsch/OpenSshCertificateBuffer.java
@@ -1,15 +1,12 @@
package com.jcraft.jsch;
-import java.nio.charset.Charset;
+import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Collection;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
-import static com.jcraft.jsch.OpenSshCertificateUtil.isEmpty;
-import static java.nio.charset.StandardCharsets.UTF_8;
-
/**
* A specialized buffer for parsing OpenSSH certificate data.
*
@@ -28,7 +25,6 @@ class OpenSshCertificateBuffer extends Buffer {
private static final byte[] EMPTY_BYTE_ARRAY = {};
-
/**
* Creates a new OpenSSH certificate buffer from decoded certificate bytes.
*
@@ -46,24 +42,13 @@ class OpenSshCertificateBuffer extends Buffer {
*
* @return the byte array data
*/
- public byte[] getBytes() {
+ byte[] getBytes() {
int reqLen = getInt();
byte[] b = new byte[reqLen];
getByte(b);
return b;
}
- /**
- * Reads a string with the specified character encoding.
- *
- * @param charset the character encoding to use
- * @return the decoded string
- */
- public String getString(Charset charset) {
- return new String(getString(), charset);
- }
-
-
/**
* Reads a collection of UTF-8 encoded strings from the buffer.
*
@@ -74,10 +59,10 @@ public String getString(Charset charset) {
*
* @return collection of strings
*/
- public Collection getStrings() {
+ Collection getStrings() {
List list = new ArrayList<>();
while (getLength() > 0) {
- String s = getString(UTF_8);
+ String s = Util.byte2str(getString(), StandardCharsets.UTF_8);
list.add(s);
}
return list;
@@ -92,7 +77,7 @@ public Collection getStrings() {
*
* @return map of critical option names to values
*/
- public Map getCriticalOptions() {
+ Map getCriticalOptions() {
return getKeyValueData();
}
@@ -105,7 +90,7 @@ public Map getCriticalOptions() {
*
* @return map of extension names to values
*/
- public Map getExtensions() {
+ Map getExtensions() {
return getKeyValueData();
}
@@ -125,25 +110,11 @@ private Map getKeyValueData() {
if (getLength() > 0) {
OpenSshCertificateBuffer keyValueDataBuffer = new OpenSshCertificateBuffer(getString());
while (keyValueDataBuffer.getLength() > 0) {
- String key = keyValueDataBuffer.getString(UTF_8);
- String value = keyValueDataBuffer.getString(UTF_8);
+ String key = Util.byte2str(keyValueDataBuffer.getString(), StandardCharsets.UTF_8);
+ String value = Util.byte2str(keyValueDataBuffer.getString(), StandardCharsets.UTF_8);
map.put(key, value);
}
}
return map;
}
-
- /**
- * Writes a UTF-8 encoded string to the buffer with length prefix.
- *
- * @param string the string
- */
- public void putString(String string) {
- if (isEmpty(string)) {
- putInt(0);
- putByte(EMPTY_BYTE_ARRAY);
- } else {
- putString(string.getBytes(UTF_8));
- }
- }
}
diff --git a/src/main/java/com/jcraft/jsch/OpenSshCertificateHostKeyVerifier.java b/src/main/java/com/jcraft/jsch/OpenSshCertificateHostKeyVerifier.java
index 944600932..c6d89287e 100644
--- a/src/main/java/com/jcraft/jsch/OpenSshCertificateHostKeyVerifier.java
+++ b/src/main/java/com/jcraft/jsch/OpenSshCertificateHostKeyVerifier.java
@@ -1,15 +1,11 @@
package com.jcraft.jsch;
import java.util.Arrays;
-import java.util.Base64;
import java.util.Collection;
import java.util.HashSet;
import java.util.Set;
import java.util.stream.Collectors;
-import static com.jcraft.jsch.OpenSshCertificateUtil.isEmpty;
-import static com.jcraft.jsch.Util.byte2str;
-
/**
* A verifier for OpenSSH host certificates.
*
@@ -25,7 +21,7 @@
*
Ensuring no unrecognized critical options are present.
*
*/
-public class OpenSshCertificateHostKeyVerifier {
+class OpenSshCertificateHostKeyVerifier {
/**
* Performs a complete verification of a host's OpenSSH certificate.
@@ -36,34 +32,38 @@ public class OpenSshCertificateHostKeyVerifier {
*
*
* @param session the current JSch session.
- * @param kex the key exchange context, which contains the host certificate.
+ * @param certificate the certificate to check.
* @throws JSchException if the certificate is invalid, expired, not signed by a trusted CA, or
* fails any other validation check. Throws specific subclasses of {@link JSchException}
* for different failure reasons.
*/
- public static void checkHostCertificate(Session session, KeyExchange kex) throws JSchException {
- OpenSshCertificate certificate = kex.getHostKeyCertificate();
+ static void checkHostCertificate(Session session, OpenSshCertificate certificate)
+ throws JSchException {
+
byte[] caPublicKeyByteArray = certificate.getSignatureKey();
- String base64CaPublicKey = Base64.getEncoder().encodeToString(caPublicKeyByteArray);
+ String base64CaPublicKey =
+ Util.byte2str(Util.toBase64(caPublicKeyByteArray, 0, caPublicKeyByteArray.length, true));
- boolean caFound = getTrustedCAs(session.getHostKeyRepository()).stream()
- .anyMatch(trustedCA -> trustedCA.isMatched(session.host) && trustedCA.getKey() != null
- && trustedCA.getKey().equals(base64CaPublicKey));
+ String host = session.host;
+ HostKeyRepository repository = session.getHostKeyRepository();
+
+ boolean caFound =
+ OpenSshCertificateUtil.isCertificateSignedByTrustedCA(repository, host, base64CaPublicKey);
if (!caFound) {
- throw new JSchUnknownCAKeyException(
- "rejected HostKey: Certification Authority not in the known hosts for " + session.host);
+ throw new JSchUnknownCAKeyException("Rejected certificate '" + certificate.getId() + "': "
+ + "Certification Authority not in the known hosts or revoked for " + host);
}
Buffer caPublicKeyBuffer = new Buffer(caPublicKeyByteArray);
- String caPublicKeyAlgorithm = byte2str(caPublicKeyBuffer.getString());
+ String caPublicKeyAlgorithm = Util.byte2str(caPublicKeyBuffer.getString());
String certificateId = certificate.getId();
// check if the certificate is a
if (!certificate.isHostCertificate()) {
throw new JSchInvalidHostCertificateException("reject HostKey: certificate id='"
- + certificateId + "' is not a host certificate. Host:" + session.host);
+ + certificateId + "' is not a host certificate. Host:" + host);
}
if (!certificate.isValidNow()) {
@@ -72,20 +72,20 @@ public static void checkHostCertificate(Session session, KeyExchange kex) throws
+ certificateId);
}
- checkSignature(certificate, caPublicKeyAlgorithm);
+ checkSignature(certificate, caPublicKeyAlgorithm, session);
// "As a special case, a zero-length "valid principals" field means the certificate is valid for
// any principal of the specified type."
// Empty principals in a host certificate mean the certificate is valid for any host.
Collection principals = certificate.getPrincipals();
if (principals != null && !principals.isEmpty()) {
- if (!principals.contains(session.host)) {
- throw new JSchException("rejected HostKey: invalid principal '" + session.host
+ if (!principals.contains(host)) {
+ throw new JSchException("rejected HostKey: invalid principal '" + host
+ "', allowed principals: " + principals);
}
}
- if (!isEmpty(certificate.getCriticalOptions())) {
+ if (!OpenSshCertificateUtil.isEmpty(certificate.getCriticalOptions())) {
// no critical option defined for host keys yet
throw new JSchInvalidHostCertificateException(
"rejected HostKey: unrecognized critical options " + certificate.getCriticalOptions());
@@ -105,9 +105,9 @@ public static void checkHostCertificate(Session session, KeyExchange kex) throws
* it returns {exponent, modulus}).
* @throws JSchException if the public key algorithm is unknown or the key format is corrupt.
*/
- public static byte[][] parsePublicKey(byte[] certificatePublicKey) throws JSchException {
+ static byte[][] parsePublicKey(byte[] certificatePublicKey) throws JSchException {
Buffer buffer = new Buffer(certificatePublicKey);
- String algorithm = byte2str(buffer.getString());
+ String algorithm = Util.byte2str(buffer.getString());
if (algorithm.startsWith("ssh-rsa") || algorithm.startsWith("rsa-")) {
byte[] ee = buffer.getMPInt();
@@ -126,7 +126,7 @@ public static byte[][] parsePublicKey(byte[] certificatePublicKey) throws JSchEx
if (algorithm.startsWith("ecdsa-sha2-")) {
// https://www.rfc-editor.org/rfc/rfc5656#section-3.1
// The string [identifier] is the identifier of the elliptic curve domain parameters.
- String identifier = byte2str(buffer.getString());
+ String identifier = Util.byte2str(buffer.getString());
int len = buffer.getInt();
int x04 = buffer.getByte();
byte[] r = new byte[(len - 1) / 2];
@@ -158,10 +158,10 @@ public static byte[][] parsePublicKey(byte[] certificatePublicKey) throws JSchEx
* @throws JSchException if the signature algorithm does not match the CA key algorithm or if the
* signature is cryptographically invalid.
*/
- private static void checkSignature(OpenSshCertificate certificate, String caPublicKeyAlgorithm)
- throws JSchException {
+ static void checkSignature(OpenSshCertificate certificate, String caPublicKeyAlgorithm,
+ Session session) throws JSchException {
// Check signature
- SignatureWrapper signature = getSignatureWrapper(certificate, caPublicKeyAlgorithm);
+ SignatureWrapper signature = getSignatureWrapper(certificate, caPublicKeyAlgorithm, session);
byte[][] publicKey = parsePublicKey(certificate.getSignatureKey());
boolean verified;
try {
@@ -192,11 +192,11 @@ private static void checkSignature(OpenSshCertificate certificate, String caPubl
* @throws JSchException if the signature algorithm does not match the CA's key algorithm, or if
* the wrapper cannot be instantiated.
*/
- private static SignatureWrapper getSignatureWrapper(OpenSshCertificate certificate,
- String caPublicKeyAlgorithm) throws JSchException {
+ static SignatureWrapper getSignatureWrapper(OpenSshCertificate certificate,
+ String caPublicKeyAlgorithm, Session session) throws JSchException {
byte[] certificateSignature = certificate.getSignature();
Buffer signatureBuffer = new Buffer(certificateSignature);
- String signatureAlgorithm = byte2str(signatureBuffer.getString());
+ String signatureAlgorithm = Util.byte2str(signatureBuffer.getString());
if (!caPublicKeyAlgorithm.equals(signatureAlgorithm)) {
throw new JSchInvalidHostCertificateException(
@@ -204,24 +204,7 @@ private static SignatureWrapper getSignatureWrapper(OpenSshCertificate certifica
+ signatureAlgorithm + "' - CA public Key algorithm: '" + caPublicKeyAlgorithm + "'");
}
- return new SignatureWrapper(signatureAlgorithm);
+ return new SignatureWrapper(signatureAlgorithm, session);
}
- /**
- * Retrieves all trusted Certificate Authority (CA) host keys from the repository.
- *
- * Trusted CAs are identified in the {@code known_hosts} file by the {@code @cert-authority}
- * marker.
- *
- *
- * @param knownHosts the repository of known hosts (typically from a known_hosts file).
- * @return a {@link Set} of {@link HostKey} objects representing the trusted CAs.
- */
-
- private static Set getTrustedCAs(HostKeyRepository knownHosts) {
- HostKey[] hostKeys = knownHosts.getHostKey();
- return hostKeys == null ? new HashSet<>()
- : Arrays.stream(hostKeys).filter(OpenSshCertificateUtil::isKnownHostCaPublicKeyEntry)
- .collect(Collectors.toSet());
- }
}
diff --git a/src/main/java/com/jcraft/jsch/OpenSshCertificateParser.java b/src/main/java/com/jcraft/jsch/OpenSshCertificateParser.java
index 6daff6274..eb4b5fab4 100644
--- a/src/main/java/com/jcraft/jsch/OpenSshCertificateParser.java
+++ b/src/main/java/com/jcraft/jsch/OpenSshCertificateParser.java
@@ -2,25 +2,9 @@
import com.jcraft.jsch.JSch.InstanceLogger;
+import java.nio.charset.StandardCharsets;
import java.util.Collection;
-import static com.jcraft.jsch.OpenSshCertificateAwareIdentityFile.ECDSA_SHA2_NISTP256_CERT;
-import static com.jcraft.jsch.OpenSshCertificateAwareIdentityFile.ECDSA_SHA2_NISTP256_CERT_V01_AT_OPENSSH_DOT_COM;
-import static com.jcraft.jsch.OpenSshCertificateAwareIdentityFile.ECDSA_SHA2_NISTP384_CERT;
-import static com.jcraft.jsch.OpenSshCertificateAwareIdentityFile.ECDSA_SHA2_NISTP384_CERT_V01_AT_OPENSSH_DOT_COM;
-import static com.jcraft.jsch.OpenSshCertificateAwareIdentityFile.ECDSA_SHA2_NISTP521_CERT;
-import static com.jcraft.jsch.OpenSshCertificateAwareIdentityFile.ECDSA_SHA2_NISTP521_CERT_V01_AT_OPENSSH_DOT_COM;
-import static com.jcraft.jsch.OpenSshCertificateAwareIdentityFile.SSH_DSS_CERT;
-import static com.jcraft.jsch.OpenSshCertificateAwareIdentityFile.SSH_DSS_CERT_V01_AT_OPENSSH_DOT_COM;
-import static com.jcraft.jsch.OpenSshCertificateAwareIdentityFile.SSH_ED25519_CERT;
-import static com.jcraft.jsch.OpenSshCertificateAwareIdentityFile.SSH_ED25519_CERT_V01_AT_OPENSSH_DOT_COM;
-import static com.jcraft.jsch.OpenSshCertificateAwareIdentityFile.SSH_ED448_CERT;
-import static com.jcraft.jsch.OpenSshCertificateAwareIdentityFile.SSH_ED448_CERT_V01_AT_OPENSSH_DOT_COM;
-import static com.jcraft.jsch.OpenSshCertificateAwareIdentityFile.SSH_RSA_CERT;
-import static com.jcraft.jsch.OpenSshCertificateAwareIdentityFile.SSH_RSA_CERT_V01_AT_OPENSSH_DOT_COM;
-import static com.jcraft.jsch.OpenSshCertificateUtil.trimToEmptyIfNull;
-import static java.nio.charset.StandardCharsets.UTF_8;
-
/**
* Parser for OpenSSH certificate format.
*
@@ -77,14 +61,16 @@ static OpenSshCertificate parse(InstanceLogger instLogger, byte[] certificateDat
OpenSshCertificate.Builder openSshCertificateBuilder = new OpenSshCertificate.Builder();
- String kTypeFromData = trimToEmptyIfNull(buffer.getString(UTF_8));
+ String kTypeFromData = OpenSshCertificateUtil
+ .trimToEmptyIfNull(Util.byte2str(buffer.getString(), StandardCharsets.UTF_8));
openSshCertificateBuilder.keyType(kTypeFromData).nonce(buffer.getString());
// KeyPair.parsePubkeyBlob expect keytype in public key blob
KeyPair publicKey = parsePublicKey(instLogger, kTypeFromData, buffer);
openSshCertificateBuilder.certificatePublicKey(publicKey.getPublicKeyBlob())
- .serial(buffer.getLong()).type(buffer.getInt()).id(buffer.getString(UTF_8));
+ .serial(buffer.getLong()).type(buffer.getInt())
+ .id(Util.byte2str(buffer.getString(), StandardCharsets.UTF_8));
// Principals
byte[] principalsBlob = buffer.getBytes();
@@ -92,7 +78,8 @@ static OpenSshCertificate parse(InstanceLogger instLogger, byte[] certificateDat
Collection principals = principalsBuffer.getStrings();
openSshCertificateBuilder.principals(principals).validAfter(buffer.getLong())
.validBefore(buffer.getLong()).criticalOptions(buffer.getCriticalOptions())
- .extensions(buffer.getExtensions()).reserved(buffer.getString(UTF_8))
+ .extensions(buffer.getExtensions())
+ .reserved(Util.byte2str(buffer.getString(), StandardCharsets.UTF_8))
.signatureKey(buffer.getString());
int messageEndIndex = buffer.s;
@@ -137,26 +124,22 @@ static KeyPair parsePublicKey(InstanceLogger instLogger, String keyType, Buffer
throws JSchException {
switch (keyType) {
- case SSH_RSA_CERT_V01_AT_OPENSSH_DOT_COM:
- case SSH_RSA_CERT:
+ case OpenSshCertificateAwareIdentityFile.SSH_RSA_CERT_V01_AT_OPENSSH_DOT_COM:
byte[] pub_array = buffer.getMPInt(); // e
byte[] n_array = buffer.getMPInt(); // n
return new KeyPairRSA(instLogger, n_array, pub_array, null);
- case SSH_DSS_CERT_V01_AT_OPENSSH_DOT_COM:
- case SSH_DSS_CERT:
+ case OpenSshCertificateAwareIdentityFile.SSH_DSS_CERT_V01_AT_OPENSSH_DOT_COM:
byte[] p_array = buffer.getMPInt();
byte[] q_array = buffer.getMPInt();
byte[] g_array = buffer.getMPInt();
byte[] y_array = buffer.getMPInt();
return new KeyPairDSA(instLogger, p_array, q_array, g_array, y_array, null);
- case ECDSA_SHA2_NISTP256_CERT_V01_AT_OPENSSH_DOT_COM:
- case ECDSA_SHA2_NISTP384_CERT_V01_AT_OPENSSH_DOT_COM:
- case ECDSA_SHA2_NISTP521_CERT_V01_AT_OPENSSH_DOT_COM:
- case ECDSA_SHA2_NISTP256_CERT:
- case ECDSA_SHA2_NISTP384_CERT:
- case ECDSA_SHA2_NISTP521_CERT:
+ case OpenSshCertificateAwareIdentityFile.ECDSA_SHA2_NISTP256_CERT_V01_AT_OPENSSH_DOT_COM:
+ case OpenSshCertificateAwareIdentityFile.ECDSA_SHA2_NISTP384_CERT_V01_AT_OPENSSH_DOT_COM:
+ case OpenSshCertificateAwareIdentityFile.ECDSA_SHA2_NISTP521_CERT_V01_AT_OPENSSH_DOT_COM:
+
byte[] name = buffer.getString();
int len = buffer.getInt();
int x04 = buffer.getByte();
@@ -166,14 +149,12 @@ static KeyPair parsePublicKey(InstanceLogger instLogger, String keyType, Buffer
buffer.getByte(s_array);
return new KeyPairECDSA(instLogger, name, r_array, s_array, null);
- case SSH_ED25519_CERT_V01_AT_OPENSSH_DOT_COM:
- case SSH_ED25519_CERT:
+ case OpenSshCertificateAwareIdentityFile.SSH_ED25519_CERT_V01_AT_OPENSSH_DOT_COM:
byte[] ed25519_pub_array = new byte[buffer.getInt()];
buffer.getByte(ed25519_pub_array);
return new KeyPairEd25519(instLogger, ed25519_pub_array, null);
- case SSH_ED448_CERT_V01_AT_OPENSSH_DOT_COM:
- case SSH_ED448_CERT:
+ case OpenSshCertificateAwareIdentityFile.SSH_ED448_CERT_V01_AT_OPENSSH_DOT_COM:
byte[] ed448_pub_array = new byte[buffer.getInt()];
buffer.getByte(ed448_pub_array);
return new KeyPairEd448(instLogger, ed448_pub_array, null);
diff --git a/src/main/java/com/jcraft/jsch/OpenSshCertificateUtil.java b/src/main/java/com/jcraft/jsch/OpenSshCertificateUtil.java
index 78bcef704..8adc06231 100644
--- a/src/main/java/com/jcraft/jsch/OpenSshCertificateUtil.java
+++ b/src/main/java/com/jcraft/jsch/OpenSshCertificateUtil.java
@@ -1,18 +1,42 @@
package com.jcraft.jsch;
-import java.io.IOException;
+import java.nio.charset.StandardCharsets;
import java.time.Instant;
+import java.util.ArrayList;
+import java.util.Arrays;
import java.util.Collection;
import java.util.Date;
+import java.util.HashSet;
+import java.util.List;
import java.util.Map;
-
-import static com.jcraft.jsch.OpenSshCertificate.SSH2_CERT_TYPE_HOST;
-import static com.jcraft.jsch.OpenSshCertificateAwareIdentityFile.isOpenSshCertificateKeyType;
-import static com.jcraft.jsch.OpenSshCertificateParser.parsePublicKey;
-import static java.nio.charset.StandardCharsets.UTF_8;
+import java.util.Objects;
+import java.util.Set;
+import java.util.function.Predicate;
+import java.util.stream.Collectors;
class OpenSshCertificateUtil {
+ /**
+ * Predicate that identifies Certificate Authority (CA) public key entries in a known_hosts file.
+ *
+ * This predicate tests whether a {@link HostKey} represents a trusted CA entry, identified by the
+ * {@code @cert-authority} marker in the known_hosts file. CA entries are used to validate OpenSSH
+ * certificates presented by hosts during authentication.
+ *
+ */
+ static Predicate isKnownHostCaPublicKeyEntry =
+ hostKey -> Objects.nonNull(hostKey) && "@cert-authority".equals(hostKey.getMarker());
+
+ /**
+ * Predicate that identifies revoked key entries in a known_hosts file.
+ *
+ * This predicate tests whether a {@link HostKey} is marked as revoked (using the {@code @revoked}
+ * marker) or is {@code null}. It implements fail-closed security semantics by treating
+ * {@code null} entries as revoked.
+ *
+ */
+ static Predicate isMarkedRevoked =
+ hostKey -> hostKey == null || "@revoked".equals(hostKey.getMarker());
/**
* Converts a byte array to a UTF-8 string, replaces tab characters with spaces, and trims
@@ -24,7 +48,7 @@ class OpenSshCertificateUtil {
* @see #tabToSpaceAndTrim(String)
*/
static String tabToSpaceAndTrim(byte[] s) {
- String str = new String(s, UTF_8);
+ String str = new String(s, StandardCharsets.UTF_8);
return tabToSpaceAndTrim(str);
}
@@ -41,7 +65,6 @@ static String tabToSpaceAndTrim(String s) {
if (s != null) {
s = s.replace('\t', ' ');
}
-
return trimToEmptyIfNull(s);
}
@@ -61,16 +84,15 @@ static String trimToEmptyIfNull(String s) {
}
/**
- * Checks if a CharSequence is empty or null.
+ * Checks if a String is empty or null.
*
- * @param cs the CharSequence to check, may be null
- * @return true if the CharSequence is null or has a length of 0 or less, false otherwise
+ * @param string the String to check, may be null
+ * @return true if the CharSequence is null or has a length of 0, false otherwise
*/
- static boolean isEmpty(CharSequence cs) {
- return cs == null || cs.length() == 0;
+ static boolean isEmpty(String string) {
+ return string == null || string.isEmpty();
}
-
/**
* Checks if a Collection is empty or null.
*
@@ -91,18 +113,16 @@ static boolean isEmpty(Map, ?> c) {
return c == null || c.isEmpty();
}
-
/**
- * Extracts the key type from a certificate file content string. This method assumes the key type
- * is the first field (index 0) in the space-delimited string.
+ * Extracts the key type from a certificate file content byte array. This method assumes the key
+ * type is the first field (index 0) in the space-delimited string.
*
- * @param certificateFileContent The content of the certificate file as a single string.
+ * @param certificateFileContent The content of the certificate file as a byte array.
* @return The key type string, or {@code null} if the content is invalid or the field does not
* exist.
* @throws IllegalArgumentException if the certificate content is null or empty after trimming.
*/
- public static String extractKeyType(String certificateFileContent)
- throws IllegalArgumentException {
+ static byte[] extractKeyType(byte[] certificateFileContent) throws IllegalArgumentException {
return extractSpaceDelimitedString(certificateFileContent, 0);
}
@@ -115,8 +135,7 @@ public static String extractKeyType(String certificateFileContent)
* exist.
* @throws IllegalArgumentException if the certificate content is null or empty after trimming.
*/
- public static String extractComment(String certificateFileContent)
- throws IllegalArgumentException {
+ static byte[] extractComment(byte[] certificateFileContent) throws IllegalArgumentException {
return extractSpaceDelimitedString(certificateFileContent, 2);
}
@@ -128,50 +147,83 @@ public static String extractComment(String certificateFileContent)
* @return The key data string, or {@code null} if the content is invalid or the field does not
* exist.
*/
- public static String extractKeyData(String certificateFileContent)
- throws IllegalArgumentException {
+ static byte[] extractKeyData(byte[] certificateFileContent) throws IllegalArgumentException {
return extractSpaceDelimitedString(certificateFileContent, 1);
}
/**
- * A private utility method to safely extract a space-delimited string from a certificate content
- * string at a given index. This method is null-safe and handles out-of-bounds indices gracefully
- * by returning {@code null}.
+ * Checks if a byte represents a whitespace character (space, tab, newline, or carriage return).
+ *
+ * @param b the byte to check
+ * @return true if the byte is a whitespace character, false otherwise
+ */
+ private static boolean isWhitespace(byte b) {
+ return b == ' ' || b == '\t' || b == '\n' || b == '\r';
+ }
+
+ /**
+ * A utility method to safely extract a space-delimited field from a certificate content byte
+ * array at a given index. This method avoids String allocation by working directly with bytes and
+ * returning a byte array. It handles whitespace (space, tab, newline, carriage return) as
+ * delimiters.
*
- * @param certificate The string to be parsed, typically representing certificate content.
+ * @param certificate The byte array to be parsed, typically representing certificate content.
* @param index The zero-based index of the field to extract.
- * @return The string field at the specified index, or {@code null} if the input is invalid or the
- * index is out of bounds.
+ * @return The byte array field at the specified index, or {@code null} if the input is invalid or
+ * the index is out of bounds.
*/
- public static String extractSpaceDelimitedString(String certificate, int index) {
- if (certificate == null || certificate.trim().isEmpty()) {
+ static byte[] extractSpaceDelimitedString(byte[] certificate, int index) {
+ if (certificate == null || certificate.length == 0) {
return null;
}
- String[] fields = certificate.split("\\s+");
- if (index >= 0 && index < fields.length) {
- return fields[index];
- } else {
- return null;
+ int fieldCount = 0;
+ int fieldStart = -1;
+ int i = 0;
+
+ // Skip leading whitespace
+ while (i < certificate.length && isWhitespace(certificate[i])) {
+ i++;
}
- }
+ while (i < certificate.length) {
+ // Found start of a field
+ if (!isWhitespace(certificate[i])) {
+ if (fieldStart == -1) {
+ fieldStart = i;
+ }
+ i++;
+ } else {
+ // Found end of a field
+ if (fieldStart != -1) {
+ if (fieldCount == index) {
+ // This is the field we want - copy and return it
+ int length = i - fieldStart;
+ byte[] result = new byte[length];
+ System.arraycopy(certificate, fieldStart, result, 0, length);
+ return result;
+ }
+ fieldCount++;
+ fieldStart = -1;
+ }
+
+ // Skip whitespace
+ while (i < certificate.length && isWhitespace(certificate[i])) {
+ i++;
+ }
+ }
+ }
+ // Handle last field (no trailing whitespace)
+ if (fieldStart != -1 && fieldCount == index) {
+ int length = i - fieldStart;
+ byte[] result = new byte[length];
+ System.arraycopy(certificate, fieldStart, result, 0, length);
+ return result;
+ }
- /**
- * Extracts the key type from encoded key data provided as a byte array. Converts the byte array
- * to a UTF-8 string and delegates to the string version of this method.
- *
- * @param s the encoded key data as a byte array, may be null
- * @return the key type as a string, or an empty string if the input is null/empty
- * @throws IllegalArgumentException if the data format is invalid (no space delimiter found)
- * @see #extractKeyType(String)
- */
- public static String extractKeyType(byte[] s) throws IllegalArgumentException {
- String str = tabToSpaceAndTrim(s);
- return extractKeyType(str);
+ return null;
}
-
/**
* Determines whether the given {@link OpenSshCertificate} is valid at the current local system
* time.
@@ -190,7 +242,7 @@ static boolean isValidNow(OpenSshCertificate cert) {
/**
* Converts a Unix timestamp to a {@link Date} string representation.
- *
+ *
* If the timestamp is negative, it indicates an infinite expiration time, and the method returns
* the string "infinity". Otherwise, it converts the timestamp from seconds to milliseconds and
* returns the default string representation of the resulting {@link Date} object.
@@ -207,7 +259,7 @@ static String toDateString(long timestamp) {
/**
* Extracts the raw key type from a given key type string.
- *
+ *
* This method searches for the first occurrence of the substring "-cert" and returns all
* characters that appear before it. If the substring is not found, the original string is
* returned unchanged.
@@ -231,7 +283,7 @@ static String getRawKeyType(String keyType) {
/**
* Checks if a given byte array represents an OpenSSH host certificate.
- *
+ *
* This method parses the provided byte array to determine if it conforms to the structure of an
* OpenSSH certificate and, if so, verifies that its type is a host certificate. It performs
* checks for null or empty input, validates the key type, and then extracts the certificate type
@@ -252,55 +304,257 @@ static boolean isOpenSshHostCertificate(JSch.InstanceLogger instLogger, byte[] b
OpenSshCertificateBuffer buffer = new OpenSshCertificateBuffer(bytes);
- String keyType = buffer.getString(UTF_8);
- if (isEmpty(keyType) || !isOpenSshCertificateKeyType(keyType)) {
+ String keyType = Util.byte2str(buffer.getString(), StandardCharsets.UTF_8);
+ if (isEmpty(keyType)
+ || !OpenSshCertificateAwareIdentityFile.isOpenSshCertificateKeyType(keyType)) {
return false;
}
// discard nonce
buffer.getString();
// public key
- parsePublicKey(instLogger, keyType, buffer);
+ OpenSshCertificateParser.parsePublicKey(instLogger, keyType, buffer);
// serial
buffer.getLong();
// type
int certificateType = buffer.getInt();
- return certificateType == SSH2_CERT_TYPE_HOST;
+ return certificateType == OpenSshCertificate.SSH2_CERT_TYPE_HOST;
}
/**
- * Reads the entire content of a specified file into a String using UTF-8 encoding. This method
- * delegates the file reading to a utility function and then converts the resulting byte array
- * into a String.
+ * Filters out OpenSSH certificate types whose underlying signature algorithms are unavailable.
+ *
+ * This method ensures that JSch does not attempt to negotiate certificate-based host key
+ * algorithms when the corresponding signature implementation is unavailable on the system. This
+ * prevents connection failures that would occur if JSch negotiated a certificate type (e.g.,
+ * {@code ssh-ed25519-cert-v01@openssh.com}) but could not verify it because the base signature
+ * algorithm (e.g., {@code ssh-ed25519}) requires Java 15+ or Bouncy Castle.
+ *
*
- * @param filePath The path to the file to be read.
- * @return A {@code String} containing the entire content of the file.
- * @throws JSchException If an error related to JSch occurs during file processing (consider if
- * this exception is truly necessary here, as basic file reading usually only throws
- * IOException).
- * @throws IOException If an I/O error occurs while reading the file.
- * @see Util#fromFile(String)
+ * Algorithm Mapping
+ *
+ * The method maintains an internal mapping between base signature algorithms and their
+ * certificate counterparts:
+ *
+ *
+ * - {@code ssh-ed25519} → {@code ssh-ed25519-cert-v01@openssh.com}
+ * - {@code ssh-ed448} → {@code ssh-ed448-cert-v01@openssh.com}
+ * - {@code ssh-rsa} → {@code ssh-rsa-cert-v01@openssh.com},
+ * {@code rsa-sha2-256-cert-v01@openssh.com}, {@code rsa-sha2-512-cert-v01@openssh.com}
+ * - {@code rsa-sha2-256} → {@code rsa-sha2-256-cert-v01@openssh.com}
+ * - {@code rsa-sha2-512} → {@code rsa-sha2-512-cert-v01@openssh.com}
+ * - {@code ssh-dss} → {@code ssh-dss-cert-v01@openssh.com}
+ * - {@code ecdsa-sha2-nistp256} → {@code ecdsa-sha2-nistp256-cert-v01@openssh.com}
+ * - {@code ecdsa-sha2-nistp384} → {@code ecdsa-sha2-nistp384-cert-v01@openssh.com}
+ * - {@code ecdsa-sha2-nistp521} → {@code ecdsa-sha2-nistp521-cert-v01@openssh.com}
+ *
+ *
+ * Note: RSA has special handling because {@code ssh-rsa} being unavailable implies that
+ * RSA signature verification is completely unavailable, so all RSA-based certificate types are
+ * removed.
+ *
+ *
+ * @param serverHostKey comma-separated list of server host key algorithms to filter. This
+ * typically contains a mix of plain key algorithms (e.g., {@code ssh-ed25519}) and
+ * certificate types (e.g., {@code ssh-ed25519-cert-v01@openssh.com}). May be {@code null}.
+ * @param unavailableSignatures array of base signature algorithms that are unavailable on this
+ * system, as determined by {@link Session#checkSignatures(String)}. Each entry is a plain
+ * algorithm name like {@code ssh-ed25519} or {@code rsa-sha2-512}. May be {@code null} or
+ * empty if all signature algorithms are available.
+ * @return the filtered comma-separated list of server host key algorithms with unavailable
+ * certificate types removed, or {@code null} if all algorithms were filtered out. If
+ * {@code unavailableSignatures} is {@code null} or empty, returns {@code serverHostKey}
+ * unchanged.
*/
- static String fromFile(String filePath) throws JSchException, IOException {
- byte[] fileContent = Util.fromFile(filePath);
- return new String(fileContent, UTF_8);
+ static String filterUnavailableCertTypes(String serverHostKey, String[] unavailableSignatures) {
+ if (unavailableSignatures == null || unavailableSignatures.length == 0) {
+ return serverHostKey;
+ }
+
+ if (JSch.getLogger().isEnabled(Logger.DEBUG)) {
+ JSch.getLogger().log(Logger.DEBUG,
+ "server_host_key proposal before removing unavailable cert types is: " + serverHostKey);
+ }
+
+ // Build list of certificate types to remove based on unavailable base signatures
+ List certsToRemove = new ArrayList();
+
+ for (String unavailableSig : unavailableSignatures) {
+ // For each unavailable signature, add corresponding certificate types
+ if ("ssh-ed25519".equals(unavailableSig)) {
+ certsToRemove.add("ssh-ed25519-cert-v01@openssh.com");
+ } else if ("ssh-ed448".equals(unavailableSig)) {
+ certsToRemove.add("ssh-ed448-cert-v01@openssh.com");
+ } else if ("ssh-rsa".equals(unavailableSig)) {
+ certsToRemove.add("ssh-rsa-cert-v01@openssh.com");
+ certsToRemove.add("rsa-sha2-256-cert-v01@openssh.com");
+ certsToRemove.add("rsa-sha2-512-cert-v01@openssh.com");
+ } else if ("rsa-sha2-256".equals(unavailableSig)) {
+ certsToRemove.add("rsa-sha2-256-cert-v01@openssh.com");
+ } else if ("rsa-sha2-512".equals(unavailableSig)) {
+ certsToRemove.add("rsa-sha2-512-cert-v01@openssh.com");
+ } else if ("ssh-dss".equals(unavailableSig)) {
+ certsToRemove.add("ssh-dss-cert-v01@openssh.com");
+ } else if ("ecdsa-sha2-nistp256".equals(unavailableSig)) {
+ certsToRemove.add("ecdsa-sha2-nistp256-cert-v01@openssh.com");
+ } else if ("ecdsa-sha2-nistp384".equals(unavailableSig)) {
+ certsToRemove.add("ecdsa-sha2-nistp384-cert-v01@openssh.com");
+ } else if ("ecdsa-sha2-nistp521".equals(unavailableSig)) {
+ certsToRemove.add("ecdsa-sha2-nistp521-cert-v01@openssh.com");
+ }
+ }
+
+ if (certsToRemove.size() > 0) {
+ String[] certsArray = new String[certsToRemove.size()];
+ certsToRemove.toArray(certsArray);
+ serverHostKey = Util.diffString(serverHostKey, certsArray);
+
+ if (JSch.getLogger().isEnabled(Logger.DEBUG)) {
+ for (String cert : certsArray) {
+ JSch.getLogger().log(Logger.DEBUG, "Removing " + cert + " (base algorithm unavailable)");
+ }
+ JSch.getLogger().log(Logger.DEBUG,
+ "server_host_key proposal after removing unavailable cert types is: " + serverHostKey);
+ }
+ }
+ return serverHostKey;
}
/**
- * Checks if the given {@code HostKey} represents a Certificate Authority (CA) public key entry. A
- * host key is considered a CA public key entry if its marker is exactly
- * {@code "@cert-authority"}.
+ * Validates that a certificate is signed by a trusted, non-revoked Certificate Authority.
+ *
+ * This method performs the critical CA validation step for OpenSSH certificate authentication. It
+ * verifies that:
+ *
+ *
+ * - The CA public key exists in the known_hosts file with {@code @cert-authority} marker
+ * - The CA entry matches the connecting host's pattern
+ * - The CA key has not been revoked (no {@code @revoked} entry for same key)
+ * - The certificate was signed by this CA (CA public key matches)
+ *
+ *
+ * Validation Flow
+ *
+ * The validation follows these steps:
+ *
+ *
+ *
+ * 1. Retrieve all {@code @cert-authority} entries from known_hosts
+ * 2. Filter to only non-null entries
+ * 3. Check each CA to ensure it hasn't been revoked
+ * 4. Test if any remaining CA:
+ * - Matches the host pattern (e.g., *.example.com matches host.example.com)
+ * - Has a public key that equals the certificate's signing CA key
+ *
+ *
+ * Revocation Checking
+ *
+ * A CA is considered revoked if there exists a {@code @revoked} entry in the known_hosts file
+ * with the same public key value. The revocation check uses
+ * {@link #hasBeenRevoked(HostKeyRepository, HostKey)} to ensure that compromised CA keys are
+ * rejected even if they appear as {@code @cert-authority}.
+ *
*
- * @param hostKey The {@link HostKey} object to check.
- * @return {@code true} if the host key's marker indicates it is a CA public key entry;
- * {@code false} otherwise, including if the {@code hostKey} is {@code null}.
- * @see com.jcraft.jsch.HostKey#getMarker()
+ *
+ * @param repository the {@link HostKeyRepository} containing known_hosts entries, must not be
+ * {@code null}
+ * @param host the hostname or host pattern being connected to (e.g., "host.example.com" or
+ * "[host.example.com]:2222"), must not be {@code null}
+ * @param base64CaPublicKey the Base64-encoded CA public key from the certificate that needs
+ * validation, must not be {@code null}
+ * @return {@code true} if a trusted, non-revoked CA matching the host pattern signed the
+ * certificate; {@code false} if no matching CA exists, all matching CAs are revoked, or
+ * the CA key doesn't match
*/
- static boolean isKnownHostCaPublicKeyEntry(HostKey hostKey) {
- if (hostKey == null) {
- return false;
+ static boolean isCertificateSignedByTrustedCA(HostKeyRepository repository, String host,
+ String base64CaPublicKey) throws JSchException {
+ final Set revokedKeys = getRevokedKeys(repository);
+ byte[] publicKeyBytes = Util.fromBase64(base64CaPublicKey.getBytes(StandardCharsets.UTF_8));
+
+ return getTrustedCAs(repository).stream().filter(Objects::nonNull)
+ .filter(hostkey -> !hasBeenRevoked(repository, hostkey)).anyMatch(trustedCA -> {
+ try {
+ byte[] trustedCAKeyBytes =
+ Util.fromBase64(trustedCA.getKey().getBytes(StandardCharsets.UTF_8));
+ return trustedCA.isWildcardMatched(host) && trustedCA.getKey() != null
+ && Arrays.equals(trustedCAKeyBytes, publicKeyBytes);
+ } catch (Exception e) {
+ return false;
+ }
+ });
+ }
+
+ /**
+ * Retrieves all trusted Certificate Authority (CA) host keys from the repository.
+ *
+ * This method extracts all entries from the known_hosts file that are marked with the
+ * {@code @cert-authority} marker, which designates them as trusted CAs for certificate-based host
+ * authentication.
+ *
+ *
+ * @param knownHosts the {@link HostKeyRepository} to query (typically populated from a
+ * known_hosts file), may be empty but must not be {@code null}
+ * @return a {@link Set} of {@link HostKey} objects representing all CA entries; returns empty set
+ * if repository is empty or contains no CA entries, never returns {@code null}
+ */
+ static Set getTrustedCAs(HostKeyRepository knownHosts) {
+ HostKey[] hostKeys = knownHosts.getHostKey();
+ return hostKeys == null ? new HashSet<>()
+ : Arrays.stream(hostKeys).filter(isKnownHostCaPublicKeyEntry).collect(Collectors.toSet());
+ }
+
+ /**
+ * Retrieves all revoked key entries from the repository.
+ *
+ * This method extracts all entries from the known_hosts file that are marked with the
+ * {@code @revoked} marker, indicating keys that have been explicitly blacklisted and must not be
+ * trusted for authentication.
+ *
+ *
+ * Revoked entries take precedence over trusted entries. If a key appears in both:
+ *
+ *
+ * - A {@code @cert-authority} or regular trusted entry, AND
+ * - A {@code @revoked} entry
+ *
+ *
+ * The key must be rejected. Use {@link #hasBeenRevoked(HostKeyRepository, HostKey)} to check if a
+ * specific key has been revoked.
+ *
+ *
+ * @param knownHosts the {@link HostKeyRepository} to query (typically populated from a
+ * known_hosts file), may be empty but must not be {@code null}
+ * @return a {@link Set} of {@link HostKey} objects representing all revoked entries (includes
+ * {@code null} entries due to fail-closed security); returns empty set if repository
+ * contains no revoked entries, never returns {@code null}
+ */
+ static Set getRevokedKeys(HostKeyRepository knownHosts) {
+ HostKey[] hostKeys = knownHosts.getHostKey();
+ return hostKeys == null ? new HashSet<>()
+ : Arrays.stream(hostKeys).filter(isMarkedRevoked).collect(Collectors.toSet());
+ }
+
+ /**
+ * Checks if a given host key has been revoked.
+ *
+ * This method determines whether a {@link HostKey} appears in the known_hosts file with the
+ * {@code @revoked} marker, indicating it should not be trusted for authentication. It compares
+ * the key's public key value against all revoked entries.
+ *
+ *
+ * @param knownHosts the {@link HostKeyRepository} to query for revoked entries, must not be
+ * {@code null}
+ * @param key the {@link HostKey} to check for revocation, may be {@code null}
+ * @return {@code true} if {@code key} is {@code null} (fail-closed) or if the key's public key
+ * value matches any {@code @revoked} entry in the repository; {@code false} if the key is
+ * valid and not revoked
+ */
+ static boolean hasBeenRevoked(HostKeyRepository knownHosts, HostKey key) {
+ if (key == null) {
+ return true;
}
- return "@cert-authority".equals(hostKey.getMarker());
+ return getRevokedKeys(knownHosts).stream().filter(Objects::nonNull)
+ .anyMatch(revokedKey -> revokedKey.getKey().equals(key.getKey()));
}
}
diff --git a/src/main/java/com/jcraft/jsch/Session.java b/src/main/java/com/jcraft/jsch/Session.java
index ab90150fa..2519a2ac5 100644
--- a/src/main/java/com/jcraft/jsch/Session.java
+++ b/src/main/java/com/jcraft/jsch/Session.java
@@ -806,6 +806,19 @@ private void send_kexinit() throws Exception {
getLogger().log(Logger.DEBUG,
"server_host_key proposal after removing unavailable algos is: " + server_host_key);
}
+
+ // Also filter out certificate types for unavailable base algorithms
+ server_host_key =
+ OpenSshCertificateUtil.filterUnavailableCertTypes(server_host_key, not_available_shks);
+ if (server_host_key == null) {
+ throw new JSchException("There are not any available sig algorithm.");
+ }
+
+ if (getLogger().isEnabled(Logger.DEBUG)) {
+ getLogger().log(Logger.DEBUG,
+ "server_host_key proposal after removing unavailable cert algos is: "
+ + server_host_key);
+ }
}
String prefer_hkr = getConfig("prefer_known_host_key_types");
@@ -929,30 +942,47 @@ private void send_extinfo() throws Exception {
}
private void checkHost(String chost, int port, KeyExchange kex) throws Exception {
- if (!kex.isOpenSshServerHostKeyType) {
- checkHostKey(chost, port, kex);
- return;
+ if (kex.isOpenSshServerHostKeyType) {
+ OpenSshCertificate certificate = kex.getHostKeyCertificate();
+ try {
+ OpenSshCertificateHostKeyVerifier.checkHostCertificate(this, certificate);
+ return;
+ } catch (JSchException e) {
+ if (getConfig("HostCertificateToKeyFallback").equals("no")) {
+ throw e;
+ }
+ byte[] K_S = certificate.getCertificatePublicKey();
+ String key_type = kex.getKeyType();
+ String key_footprint = kex.getFingerPrint();
+ String keyAlgorithmName = kex.getKeyAlgorithName();
+ doCheckHostKey(chost, key_type, key_footprint, keyAlgorithmName, K_S);
+ }
}
- OpenSshCertificateHostKeyVerifier.checkHostCertificate(this, kex);
+ checkHostKey(chost, port, kex);
}
private void checkHostKey(String chost, int port, KeyExchange kex) throws JSchException {
- String shkc = getConfig("StrictHostKeyChecking");
-
if (hostKeyAlias != null) {
chost = hostKeyAlias;
}
-
// System.err.println("shkc: "+shkc);
-
byte[] K_S = kex.getHostKey();
String key_type = kex.getKeyType();
String key_fprint = kex.getFingerPrint();
+ String keyAlgorithmName = kex.getKeyAlgorithName();
+
+ doCheckHostKey(chost, key_type, key_fprint, keyAlgorithmName, K_S);
+ }
+
+ private void doCheckHostKey(String chost, String key_type, String key_fprint,
+ String keyAlgorithmName, byte[] K_S) throws JSchException {
if (hostKeyAlias == null && port != 22) {
chost = ("[" + chost + "]:" + port);
}
+ String shkc = getConfig("StrictHostKeyChecking");
+
HostKeyRepository hkr = getHostKeyRepository();
String hkh = getConfig("HashKnownHosts");
@@ -1001,7 +1031,7 @@ private void checkHostKey(String chost, int port, KeyExchange kex) throws JSchEx
}
synchronized (hkr) {
- hkr.remove(chost, kex.getKeyAlgorithName(), null);
+ hkr.remove(chost, keyAlgorithmName, null);
insert = true;
}
}
@@ -1033,7 +1063,7 @@ private void checkHostKey(String chost, int port, KeyExchange kex) throws JSchEx
}
if (i == HostKeyRepository.OK) {
- HostKey[] keys = hkr.getHostKey(chost, kex.getKeyAlgorithName());
+ HostKey[] keys = hkr.getHostKey(chost, keyAlgorithmName);
String _key = Util.byte2str(Util.toBase64(K_S, 0, K_S.length, true));
for (int j = 0; j < keys.length; j++) {
if (keys[j].getKey().equals(_key) && keys[j].getMarker().equals("@revoked")) {
@@ -2454,6 +2484,7 @@ public void setPortForwardingR(int rport, String host, int lport, SocketFactory
}
// TODO: This method should return the integer value as the assigned port.
+
/**
* Registers the remote port forwarding. If bind_address is an empty string or
* "*", the port should be available from all interfaces. If
diff --git a/src/main/java/com/jcraft/jsch/SignatureWrapper.java b/src/main/java/com/jcraft/jsch/SignatureWrapper.java
index c71b20012..d01e2eedd 100644
--- a/src/main/java/com/jcraft/jsch/SignatureWrapper.java
+++ b/src/main/java/com/jcraft/jsch/SignatureWrapper.java
@@ -19,18 +19,6 @@ class SignatureWrapper implements Signature {
private final PubKeyParameterValidator pubKeyParameterValidator;
- /**
- * Constructs a {@code SignatureWrapper} by parsing the algorithm name from a signature data blob.
- *
- * @param dataSignature a byte array containing the OpenSSH certificate buffer, from which the
- * signature algorithm name is extracted.
- * @throws JSchException if the algorithm name cannot be parsed or if the underlying signature
- * instance cannot be created.
- */
- public SignatureWrapper(byte[] dataSignature) throws JSchException {
- this(new OpenSshCertificateBuffer(dataSignature).getString(StandardCharsets.UTF_8));
- }
-
/**
* Constructs a {@code SignatureWrapper} for the specified signature algorithm.
*
@@ -43,9 +31,10 @@ public SignatureWrapper(byte[] dataSignature) throws JSchException {
* @throws JSchException if the specified algorithm is not recognized, the implementation class
* cannot be found, or an instance cannot be created.
*/
- public SignatureWrapper(String algorithm) throws JSchException {
+ SignatureWrapper(String algorithm, Session session) throws JSchException {
try {
- this.signature = Class.forName(JSch.getConfig(algorithm)).asSubclass(Signature.class)
+ // Session.getConfig(algorithm)
+ this.signature = Class.forName(session.getConfig(algorithm)).asSubclass(Signature.class)
.getDeclaredConstructor().newInstance();
} catch (Exception e) {
throw new JSchException("Failed to instantiate signature for algorithm '" + algorithm + "'",
@@ -149,7 +138,7 @@ public byte[] sign() throws Exception {
* @throws Exception if the key parameters are invalid or if an error occurs while setting the
* public key on the underlying signature instance.
*/
- public void setPubKey(byte[]... args) throws Exception {
+ void setPubKey(byte[]... args) throws Exception {
pubKeyParameterValidator.validatePublicKeyParameter(args);
publicKeySetter.setPubKey(args);
}
diff --git a/src/main/java/com/jcraft/jsch/UserAuthPublicKey.java b/src/main/java/com/jcraft/jsch/UserAuthPublicKey.java
index 4a458bf5a..f0b2b48d9 100644
--- a/src/main/java/com/jcraft/jsch/UserAuthPublicKey.java
+++ b/src/main/java/com/jcraft/jsch/UserAuthPublicKey.java
@@ -33,7 +33,6 @@
import java.util.List;
import java.util.Vector;
-
class UserAuthPublicKey extends UserAuth {
@Override
diff --git a/src/main/java/com/jcraft/jsch/Util.java b/src/main/java/com/jcraft/jsch/Util.java
index b7ef747d2..dd892a9cf 100644
--- a/src/main/java/com/jcraft/jsch/Util.java
+++ b/src/main/java/com/jcraft/jsch/Util.java
@@ -52,6 +52,10 @@ private static byte val(byte foo) {
return 0;
}
+ static byte[] fromBase64(byte[] buf) throws JSchException {
+ return fromBase64(buf, 0, buf.length);
+ }
+
static byte[] fromBase64(byte[] buf, int start, int length) throws JSchException {
try {
byte[] foo = new byte[length];
diff --git a/src/test/java/com/jcraft/jsch/HostCertificateIT.java b/src/test/java/com/jcraft/jsch/HostCertificateIT.java
index bbc21b8d8..ee400cd43 100644
--- a/src/test/java/com/jcraft/jsch/HostCertificateIT.java
+++ b/src/test/java/com/jcraft/jsch/HostCertificateIT.java
@@ -1,5 +1,7 @@
package com.jcraft.jsch;
+import java.util.Arrays;
+import java.util.List;
import com.github.valfirst.slf4jtest.LoggingEvent;
import com.github.valfirst.slf4jtest.TestLogger;
import com.github.valfirst.slf4jtest.TestLoggerFactory;
@@ -8,13 +10,8 @@
import org.testcontainers.containers.GenericContainer;
import org.testcontainers.containers.wait.strategy.Wait;
import org.testcontainers.images.builder.ImageFromDockerfile;
-import org.testcontainers.images.builder.dockerfile.DockerfileBuilder;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
-
-import java.util.Arrays;
-import java.util.List;
-
import static com.jcraft.jsch.ResourceUtil.getResourceFile;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import static org.junit.jupiter.api.Assertions.assertThrows;
@@ -106,7 +103,7 @@ public void hostKeyTestHappyPath(String algorithm) throws Exception {
ssh.setKnownHosts(getResourceFile(this.getClass(), "certificates/known_hosts"));
Session session =
- ssh.getSession("luigi", sshdContainer.getHost(), sshdContainer.getFirstMappedPort());
+ ssh.getSession("root", sshdContainer.getHost(), sshdContainer.getFirstMappedPort());
session.setConfig("enable_auth_none", "no");
session.setConfig("StrictHostKeyChecking", "yes");
session.setConfig("PreferredAuthentications", "publickey");
@@ -143,7 +140,6 @@ public void hostKeyTestNotTrustedCA(String algorithm) throws Exception {
});
}
-
/**
* Helper method to create and configure a JSch {@link Session} with common settings for the
* tests.
@@ -155,7 +151,7 @@ public void hostKeyTestNotTrustedCA(String algorithm) throws Exception {
*/
private Session setup(JSch ssh, String algorithm) throws JSchException {
Session session =
- ssh.getSession("luigi", sshdContainer.getHost(), sshdContainer.getFirstMappedPort());
+ ssh.getSession("root", sshdContainer.getHost(), sshdContainer.getFirstMappedPort());
session.setConfig("enable_auth_none", "no");
session.setConfig("StrictHostKeyChecking", "yes");
session.setConfig("PreferredAuthentications", "publickey");
diff --git a/src/test/java/com/jcraft/jsch/HostKeyTest.java b/src/test/java/com/jcraft/jsch/HostKeyTest.java
new file mode 100644
index 000000000..5862496ec
--- /dev/null
+++ b/src/test/java/com/jcraft/jsch/HostKeyTest.java
@@ -0,0 +1,269 @@
+package com.jcraft.jsch;
+
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import org.junit.jupiter.api.Test;
+
+public class HostKeyTest {
+
+ // Helper method to create a simple HostKey for testing
+ private HostKey createHostKey(String hostPattern) throws Exception {
+ // Create a dummy RSA key (just needs valid Base64 for the test)
+ String dummyKey = "AAAAB3NzaC1yc2EAAAADAQABAAABAQ==";
+ byte[] keyBytes = Util.fromBase64(Util.str2byte(dummyKey), 0, dummyKey.length());
+ return new HostKey(hostPattern, HostKey.SSHRSA, keyBytes);
+ }
+
+ // ==================== Basic wildcard tests ====================
+
+ @Test
+ public void testIsWildcardMatched_exactMatch() throws Exception {
+ HostKey hostKey = createHostKey("example.com");
+ assertTrue(hostKey.isWildcardMatched("example.com"), "Should match exact hostname");
+ }
+
+ @Test
+ public void testIsWildcardMatched_noMatch() throws Exception {
+ HostKey hostKey = createHostKey("example.com");
+ assertFalse(hostKey.isWildcardMatched("different.com"), "Should not match different hostname");
+ }
+
+ @Test
+ public void testIsWildcardMatched_nullHostname() throws Exception {
+ HostKey hostKey = createHostKey("example.com");
+ assertFalse(hostKey.isWildcardMatched(null), "Should return false for null hostname");
+ }
+
+ // ==================== Single asterisk (*) wildcard tests ====================
+
+ @Test
+ public void testIsWildcardMatched_asteriskPrefix() throws Exception {
+ HostKey hostKey = createHostKey("*.example.com");
+ assertTrue(hostKey.isWildcardMatched("host.example.com"),
+ "Should match *.example.com with host.example.com");
+ assertTrue(hostKey.isWildcardMatched("sub.example.com"),
+ "Should match *.example.com with sub.example.com");
+ assertTrue(hostKey.isWildcardMatched("a.example.com"),
+ "Should match *.example.com with a.example.com");
+ }
+
+ @Test
+ public void testIsWildcardMatched_asteriskPrefixNoMatch() throws Exception {
+ HostKey hostKey = createHostKey("*.example.com");
+ assertFalse(hostKey.isWildcardMatched("example.com"),
+ "Should not match *.example.com with example.com (no subdomain)");
+ assertFalse(hostKey.isWildcardMatched("host.different.com"),
+ "Should not match *.example.com with host.different.com");
+ }
+
+ @Test
+ public void testIsWildcardMatched_asteriskSuffix() throws Exception {
+ HostKey hostKey = createHostKey("192.168.1.*");
+ assertTrue(hostKey.isWildcardMatched("192.168.1.1"),
+ "Should match 192.168.1.* with 192.168.1.1");
+ assertTrue(hostKey.isWildcardMatched("192.168.1.100"),
+ "Should match 192.168.1.* with 192.168.1.100");
+ assertTrue(hostKey.isWildcardMatched("192.168.1.254"),
+ "Should match 192.168.1.* with 192.168.1.254");
+ }
+
+ @Test
+ public void testIsWildcardMatched_asteriskSuffixNoMatch() throws Exception {
+ HostKey hostKey = createHostKey("192.168.1.*");
+ assertFalse(hostKey.isWildcardMatched("192.168.2.1"),
+ "Should not match 192.168.1.* with 192.168.2.1");
+ assertFalse(hostKey.isWildcardMatched("192.168.1"),
+ "Should not match 192.168.1.* with 192.168.1 (incomplete)");
+ }
+
+ @Test
+ public void testIsWildcardMatched_asteriskMiddle() throws Exception {
+ HostKey hostKey = createHostKey("host-*.example.com");
+ assertTrue(hostKey.isWildcardMatched("host-1.example.com"),
+ "Should match host-*.example.com with host-1.example.com");
+ assertTrue(hostKey.isWildcardMatched("host-prod.example.com"),
+ "Should match host-*.example.com with host-prod.example.com");
+ }
+
+ @Test
+ public void testIsWildcardMatched_multipleAsterisks() throws Exception {
+ HostKey hostKey = createHostKey("*.*.example.com");
+ assertTrue(hostKey.isWildcardMatched("sub.host.example.com"),
+ "Should match *.*.example.com with sub.host.example.com");
+ assertTrue(hostKey.isWildcardMatched("a.b.example.com"),
+ "Should match *.*.example.com with a.b.example.com");
+ }
+
+ @Test
+ public void testIsWildcardMatched_asteriskMatchesEmpty() throws Exception {
+ HostKey hostKey = createHostKey("host*.example.com");
+ assertTrue(hostKey.isWildcardMatched("host.example.com"),
+ "Should match host*.example.com with host.example.com (* matches empty string)");
+ assertTrue(hostKey.isWildcardMatched("host123.example.com"),
+ "Should match host*.example.com with host123.example.com");
+ }
+
+ @Test
+ public void testIsWildcardMatched_asteriskOnly() throws Exception {
+ HostKey hostKey = createHostKey("*");
+ assertTrue(hostKey.isWildcardMatched("anything.com"), "Should match * with anything.com");
+ assertTrue(hostKey.isWildcardMatched("192.168.1.1"), "Should match * with 192.168.1.1");
+ assertTrue(hostKey.isWildcardMatched("host"), "Should match * with host");
+ }
+
+ // ==================== Question mark (?) wildcard tests ====================
+
+ @Test
+ public void testIsWildcardMatched_questionMarkSingle() throws Exception {
+ HostKey hostKey = createHostKey("host?.example.com");
+ assertTrue(hostKey.isWildcardMatched("host1.example.com"),
+ "Should match host?.example.com with host1.example.com");
+ assertTrue(hostKey.isWildcardMatched("hosta.example.com"),
+ "Should match host?.example.com with hosta.example.com");
+ assertTrue(hostKey.isWildcardMatched("host-.example.com"),
+ "Should match host?.example.com with host-.example.com");
+ }
+
+ @Test
+ public void testIsWildcardMatched_questionMarkNoMatch() throws Exception {
+ HostKey hostKey = createHostKey("host?.example.com");
+ assertFalse(hostKey.isWildcardMatched("host.example.com"),
+ "Should not match host?.example.com with host.example.com (missing character)");
+ assertFalse(hostKey.isWildcardMatched("host12.example.com"),
+ "Should not match host?.example.com with host12.example.com (too many characters)");
+ }
+
+ @Test
+ public void testIsWildcardMatched_multipleQuestionMarks() throws Exception {
+ HostKey hostKey = createHostKey("host-???.example.com");
+ assertTrue(hostKey.isWildcardMatched("host-001.example.com"),
+ "Should match host-???.example.com with host-001.example.com");
+ assertTrue(hostKey.isWildcardMatched("host-abc.example.com"),
+ "Should match host-???.example.com with host-abc.example.com");
+ assertFalse(hostKey.isWildcardMatched("host-12.example.com"),
+ "Should not match host-???.example.com with host-12.example.com (too few characters)");
+ }
+
+ // ==================== Mixed wildcard tests ====================
+
+ @Test
+ public void testIsWildcardMatched_mixedWildcards() throws Exception {
+ HostKey hostKey = createHostKey("host-?-*.example.com");
+ assertTrue(hostKey.isWildcardMatched("host-1-prod.example.com"),
+ "Should match host-?-*.example.com with host-1-prod.example.com");
+ assertTrue(hostKey.isWildcardMatched("host-a-test.example.com"),
+ "Should match host-?-*.example.com with host-a-test.example.com");
+ }
+
+ // ==================== Comma-separated patterns ====================
+
+ @Test
+ public void testIsWildcardMatched_commaSeparatedFirstMatches() throws Exception {
+ HostKey hostKey = createHostKey("host1.com,host2.com,host3.com");
+ assertTrue(hostKey.isWildcardMatched("host1.com"),
+ "Should match first pattern in comma-separated list");
+ }
+
+ @Test
+ public void testIsWildcardMatched_commaSeparatedMiddleMatches() throws Exception {
+ HostKey hostKey = createHostKey("host1.com,host2.com,host3.com");
+ assertTrue(hostKey.isWildcardMatched("host2.com"),
+ "Should match middle pattern in comma-separated list");
+ }
+
+ @Test
+ public void testIsWildcardMatched_commaSeparatedLastMatches() throws Exception {
+ HostKey hostKey = createHostKey("host1.com,host2.com,host3.com");
+ assertTrue(hostKey.isWildcardMatched("host3.com"),
+ "Should match last pattern in comma-separated list");
+ }
+
+ @Test
+ public void testIsWildcardMatched_commaSeparatedNoMatch() throws Exception {
+ HostKey hostKey = createHostKey("host1.com,host2.com,host3.com");
+ assertFalse(hostKey.isWildcardMatched("host4.com"),
+ "Should not match when hostname doesn't match any pattern in list");
+ }
+
+ @Test
+ public void testIsWildcardMatched_commaSeparatedWithWildcards() throws Exception {
+ HostKey hostKey = createHostKey("*.prod.com,*.test.com,192.168.*");
+ assertTrue(hostKey.isWildcardMatched("host.prod.com"),
+ "Should match *.prod.com in comma-separated wildcard list");
+ assertTrue(hostKey.isWildcardMatched("server.test.com"),
+ "Should match *.test.com in comma-separated wildcard list");
+ assertTrue(hostKey.isWildcardMatched("192.168.1.1"),
+ "Should match 192.168.* in comma-separated wildcard list");
+ assertFalse(hostKey.isWildcardMatched("host.dev.com"),
+ "Should not match when hostname doesn't match any wildcard pattern");
+ }
+
+ @Test
+ public void testIsWildcardMatched_commaSeparatedWithSpaces() throws Exception {
+ HostKey hostKey = createHostKey("host1.com, host2.com , host3.com");
+ assertTrue(hostKey.isWildcardMatched("host1.com"), "Should handle spaces after comma");
+ assertTrue(hostKey.isWildcardMatched("host2.com"), "Should handle spaces around comma");
+ assertTrue(hostKey.isWildcardMatched("host3.com"), "Should handle leading space in pattern");
+ }
+
+ // ==================== Edge cases ====================
+
+ @Test
+ public void testIsWildcardMatched_emptyPattern() throws Exception {
+ HostKey hostKey = createHostKey("");
+ assertFalse(hostKey.isWildcardMatched("host.com"), "Should not match empty pattern");
+ }
+
+ @Test
+ public void testIsWildcardMatched_emptyHostname() throws Exception {
+ HostKey hostKey = createHostKey("host.com");
+ assertFalse(hostKey.isWildcardMatched(""), "Should not match empty hostname");
+ }
+
+ @Test
+ public void testIsWildcardMatched_caseSensitive() throws Exception {
+ HostKey hostKey = createHostKey("Host.Example.COM");
+ // OpenSSH wildcard matching is case-sensitive
+ assertFalse(hostKey.isWildcardMatched("host.example.com"),
+ "Wildcard matching should be case-sensitive");
+ assertTrue(hostKey.isWildcardMatched("Host.Example.COM"), "Should match exact case");
+ }
+
+ @Test
+ public void testIsWildcardMatched_specialCharacters() throws Exception {
+ HostKey hostKey = createHostKey("host-1_2.example.com");
+ assertTrue(hostKey.isWildcardMatched("host-1_2.example.com"),
+ "Should match special characters in hostname");
+ }
+
+ // ==================== Real-world scenarios ====================
+
+ @Test
+ public void testIsWildcardMatched_wildcardSubdomain() throws Exception {
+ HostKey hostKey = createHostKey("*.corp.example.com");
+ assertTrue(hostKey.isWildcardMatched("server.corp.example.com"),
+ "Should match subdomain with wildcard");
+ assertTrue(hostKey.isWildcardMatched("db.corp.example.com"),
+ "Should match different subdomain with wildcard");
+ assertFalse(hostKey.isWildcardMatched("corp.example.com"),
+ "Should not match base domain without subdomain");
+ }
+
+ @Test
+ public void testIsWildcardMatched_ipv4Range() throws Exception {
+ HostKey hostKey = createHostKey("10.0.0.*");
+ assertTrue(hostKey.isWildcardMatched("10.0.0.1"), "Should match IP in range");
+ assertTrue(hostKey.isWildcardMatched("10.0.0.255"), "Should match last IP in range");
+ assertFalse(hostKey.isWildcardMatched("10.0.1.1"), "Should not match IP outside range");
+ }
+
+ @Test
+ public void testIsWildcardMatched_hostnameWithPort() throws Exception {
+ HostKey hostKey = createHostKey("[*.example.com]:2222");
+ assertTrue(hostKey.isWildcardMatched("[host.example.com]:2222"),
+ "Should match hostname with port and wildcard");
+ assertFalse(hostKey.isWildcardMatched("[host.example.com]:22"),
+ "Should not match different port");
+ }
+}
diff --git a/src/test/java/com/jcraft/jsch/OpenSshCertificateAwareIdentityFileTest.java b/src/test/java/com/jcraft/jsch/OpenSshCertificateAwareIdentityFileTest.java
new file mode 100644
index 000000000..d30d5bdda
--- /dev/null
+++ b/src/test/java/com/jcraft/jsch/OpenSshCertificateAwareIdentityFileTest.java
@@ -0,0 +1,321 @@
+package com.jcraft.jsch;
+
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.nio.charset.StandardCharsets;
+import java.util.Arrays;
+
+import org.junit.jupiter.api.Test;
+
+public class OpenSshCertificateAwareIdentityFileTest {
+
+ @Test
+ public void testIsOpenSshCertificate_File_nullInput() {
+ assertFalse(OpenSshCertificateAwareIdentityFile.isOpenSshCertificateFile(null));
+ }
+
+ @Test
+ public void testIsOpenSshCertificate_File_emptyInput() {
+ byte[] empty = new byte[0];
+ assertFalse(OpenSshCertificateAwareIdentityFile.isOpenSshCertificateFile(empty));
+ }
+
+ @Test
+ public void testIsOpenSshCertificate_sshRsaCertFile() {
+ String certType = "ssh-rsa-cert-v01@openssh.com";
+ byte[] input = certType.getBytes(StandardCharsets.UTF_8);
+ assertTrue(OpenSshCertificateAwareIdentityFile.isOpenSshCertificateFile(input));
+ }
+
+ @Test
+ public void testIsOpenSshCertificate_sshRsaCertWithDataFile() {
+ String certLine = "ssh-rsa-cert-v01@openssh.com AAAAHHNzaC1yc2EtY2VydC12MDFAb3BlbnNzaC5jb20=";
+ byte[] input = certLine.getBytes(StandardCharsets.UTF_8);
+ assertTrue(OpenSshCertificateAwareIdentityFile.isOpenSshCertificateFile(input));
+ }
+
+ @Test
+ public void testIsOpenSshCertificate_sshRsaCertWithDataAndCommentFile() {
+ String certLine =
+ "ssh-rsa-cert-v01@openssh.com AAAAHHNzaC1yc2EtY2VydC12MDFAb3BlbnNzaC5jb20= user@host";
+ byte[] input = certLine.getBytes(StandardCharsets.UTF_8);
+ assertTrue(OpenSshCertificateAwareIdentityFile.isOpenSshCertificateFile(input));
+ }
+
+ @Test
+ public void testIsOpenSshCertificate_sshDssCertFile() {
+ String certType = "ssh-dss-cert-v01@openssh.com";
+ byte[] input = certType.getBytes(StandardCharsets.UTF_8);
+ assertTrue(OpenSshCertificateAwareIdentityFile.isOpenSshCertificateFile(input));
+ }
+
+ @Test
+ public void testIsOpenSshCertificate_sshDssCertWithDataFile() {
+ String certLine =
+ "ssh-dss-cert-v01@openssh.com AAAAHHNzaC1kc3MtY2VydC12MDFAb3BlbnNzaC5jb20= user@host";
+ byte[] input = certLine.getBytes(StandardCharsets.UTF_8);
+ assertTrue(OpenSshCertificateAwareIdentityFile.isOpenSshCertificateFile(input));
+ }
+
+ @Test
+ public void testIsOpenSshCertificate_File_ecdsaSha2Nistp256Cert() {
+ String certType = "ecdsa-sha2-nistp256-cert-v01@openssh.com";
+ byte[] input = certType.getBytes(StandardCharsets.UTF_8);
+ assertTrue(OpenSshCertificateAwareIdentityFile.isOpenSshCertificateFile(input));
+ }
+
+ @Test
+ public void testIsOpenSshCertificate_File_ecdsaSha2Nistp256CertWithData() {
+ String certLine =
+ "ecdsa-sha2-nistp256-cert-v01@openssh.com AAAAKGVjZHNhLXNoYTItbmlzdHAyNTYtY2VydC12MDFAb3BlbnNzaC5jb20= user@host";
+ byte[] input = certLine.getBytes(StandardCharsets.UTF_8);
+ assertTrue(OpenSshCertificateAwareIdentityFile.isOpenSshCertificateFile(input));
+ }
+
+ @Test
+ public void testIsOpenSshCertificate_File_ecdsaSha2Nistp384Cert() {
+ String certType = "ecdsa-sha2-nistp384-cert-v01@openssh.com";
+ byte[] input = certType.getBytes(StandardCharsets.UTF_8);
+ assertTrue(OpenSshCertificateAwareIdentityFile.isOpenSshCertificateFile(input));
+ }
+
+ @Test
+ public void testIsOpenSshCertificate_File_ecdsaSha2Nistp384CertWithData() {
+ String certLine =
+ "ecdsa-sha2-nistp384-cert-v01@openssh.com AAAAKGVjZHNhLXNoYTItbmlzdHAzODQtY2VydC12MDFAb3BlbnNzaC5jb20= user@host";
+ byte[] input = certLine.getBytes(StandardCharsets.UTF_8);
+ assertTrue(OpenSshCertificateAwareIdentityFile.isOpenSshCertificateFile(input));
+ }
+
+ @Test
+ public void testIsOpenSshCertificate_File_ecdsaSha2Nistp521Cert() {
+ String certType = "ecdsa-sha2-nistp521-cert-v01@openssh.com";
+ byte[] input = certType.getBytes(StandardCharsets.UTF_8);
+ assertTrue(OpenSshCertificateAwareIdentityFile.isOpenSshCertificateFile(input));
+ }
+
+ @Test
+ public void testIsOpenSshCertificate_File_ecdsaSha2Nistp521CertWithData() {
+ String certLine =
+ "ecdsa-sha2-nistp521-cert-v01@openssh.com AAAAKGVjZHNhLXNoYTItbmlzdHA1MjEtY2VydC12MDFAb3BlbnNzaC5jb20= user@host";
+ byte[] input = certLine.getBytes(StandardCharsets.UTF_8);
+ assertTrue(OpenSshCertificateAwareIdentityFile.isOpenSshCertificateFile(input));
+ }
+
+ @Test
+ public void testIsOpenSshCertificate_sshEd25519CertFile() {
+ String certType = "ssh-ed25519-cert-v01@openssh.com";
+ byte[] input = certType.getBytes(StandardCharsets.UTF_8);
+ assertTrue(OpenSshCertificateAwareIdentityFile.isOpenSshCertificateFile(input));
+ }
+
+ @Test
+ public void testIsOpenSshCertificate_sshEd25519CertWithDataFile() {
+ String certLine =
+ "ssh-ed25519-cert-v01@openssh.com AAAAIHNzaC1lZDI1NTE5LWNlcnQtdjAxQG9wZW5zc2guY29t user@host";
+ byte[] input = certLine.getBytes(StandardCharsets.UTF_8);
+ assertTrue(OpenSshCertificateAwareIdentityFile.isOpenSshCertificateFile(input));
+ }
+
+ @Test
+ public void testIsOpenSshCertificate_sshEd448CertFile() {
+ String certType = "ssh-ed448-cert-v01@openssh.com";
+ byte[] input = certType.getBytes(StandardCharsets.UTF_8);
+ assertTrue(OpenSshCertificateAwareIdentityFile.isOpenSshCertificateFile(input));
+ }
+
+ @Test
+ public void testIsOpenSshCertificate_sshEd448CertWithDataFile() {
+ String certLine =
+ "ssh-ed448-cert-v01@openssh.com AAAAHnNzaC1lZDQ0OC1jZXJ0LXYwMUBvcGVuc3NoLmNvbQ== user@host";
+ byte[] input = certLine.getBytes(StandardCharsets.UTF_8);
+ assertTrue(OpenSshCertificateAwareIdentityFile.isOpenSshCertificateFile(input));
+ }
+
+ @Test
+ public void testIsOpenSshCertificate_regularSshRsaKeyFile() {
+ String keyType = "ssh-rsa";
+ byte[] input = keyType.getBytes(StandardCharsets.UTF_8);
+ assertFalse(OpenSshCertificateAwareIdentityFile.isOpenSshCertificateFile(input));
+ }
+
+ @Test
+ public void testIsOpenSshCertificate_regularSshRsaKeyWithDataFile() {
+ String keyLine = "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQC= user@host";
+ byte[] input = keyLine.getBytes(StandardCharsets.UTF_8);
+ assertFalse(OpenSshCertificateAwareIdentityFile.isOpenSshCertificateFile(input));
+ }
+
+ @Test
+ public void testIsOpenSshCertificate_regularSshDssKeyFile() {
+ String keyType = "ssh-dss";
+ byte[] input = keyType.getBytes(StandardCharsets.UTF_8);
+ assertFalse(OpenSshCertificateAwareIdentityFile.isOpenSshCertificateFile(input));
+ }
+
+ @Test
+ public void testIsOpenSshCertificate_File_regularEcdsaKey() {
+ String keyType = "ecdsa-sha2-nistp256";
+ byte[] input = keyType.getBytes(StandardCharsets.UTF_8);
+ assertFalse(OpenSshCertificateAwareIdentityFile.isOpenSshCertificateFile(input));
+ }
+
+ @Test
+ public void testIsOpenSshCertificate_File_regularEd25519Key() {
+ String keyType = "ssh-ed25519";
+ byte[] input = keyType.getBytes(StandardCharsets.UTF_8);
+ assertFalse(OpenSshCertificateAwareIdentityFile.isOpenSshCertificateFile(input));
+ }
+
+ @Test
+ public void testIsOpenSshCertificate_File_tooShort() {
+ String tooShort = "short";
+ byte[] input = tooShort.getBytes(StandardCharsets.UTF_8);
+ assertFalse(OpenSshCertificateAwareIdentityFile.isOpenSshCertificateFile(input));
+ }
+
+ @Test
+ public void testIsOpenSshCertificate_File_wrongSuffix() {
+ String wrongSuffix = "ssh-rsa-cert-v02@openssh.com";
+ byte[] input = wrongSuffix.getBytes(StandardCharsets.UTF_8);
+ assertFalse(OpenSshCertificateAwareIdentityFile.isOpenSshCertificateFile(input));
+ }
+
+ @Test
+ public void testIsOpenSshCertificate_File_wrongPrefix() {
+ String wrongPrefix = "ssh-abc-cert-v01@openssh.com";
+ byte[] input = wrongPrefix.getBytes(StandardCharsets.UTF_8);
+ assertFalse(OpenSshCertificateAwareIdentityFile.isOpenSshCertificateFile(input));
+ }
+
+ @Test
+ public void testIsOpenSshCertificate_File_invalidAlgorithm() {
+ String invalid = "invalid-cert-v01@openssh.com";
+ byte[] input = invalid.getBytes(StandardCharsets.UTF_8);
+ assertFalse(OpenSshCertificateAwareIdentityFile.isOpenSshCertificateFile(input));
+ }
+
+ @Test
+ public void testIsOpenSshCertificate_File_onlyWhitespace() {
+ String whitespace = " \t\n";
+ byte[] input = whitespace.getBytes(StandardCharsets.UTF_8);
+ assertFalse(OpenSshCertificateAwareIdentityFile.isOpenSshCertificateFile(input));
+ }
+
+ @Test
+ public void testIsOpenSshCertificate_File_leadingWhitespace() {
+ String certLine =
+ " ssh-rsa-cert-v01@openssh.com AAAAHHNzaC1yc2EtY2VydC12MDFAb3BlbnNzaC5jb20=";
+ byte[] input = certLine.getBytes(StandardCharsets.UTF_8);
+ assertTrue(OpenSshCertificateAwareIdentityFile.isOpenSshCertificateFile(input));
+ }
+
+ @Test
+ public void testIsOpenSshCertificate_File_tabDelimiter() {
+ String certLine = "ssh-rsa-cert-v01@openssh.com\tAAAAHHNzaC1yc2EtY2VydC12MDFAb3BlbnNzaC5jb20=";
+ byte[] input = certLine.getBytes(StandardCharsets.UTF_8);
+ assertTrue(OpenSshCertificateAwareIdentityFile.isOpenSshCertificateFile(input));
+ }
+
+ @Test
+ public void testIsOpenSshCertificate_File_newlineDelimiter() {
+ String certLine = "ssh-rsa-cert-v01@openssh.com\nAAAAHHNzaC1yc2EtY2VydC12MDFAb3BlbnNzaC5jb20=";
+ byte[] input = certLine.getBytes(StandardCharsets.UTF_8);
+ assertTrue(OpenSshCertificateAwareIdentityFile.isOpenSshCertificateFile(input));
+ }
+
+ @Test
+ public void testIsOpenSshCertificate_File_carriageReturnDelimiter() {
+ String certLine = "ssh-rsa-cert-v01@openssh.com\rAAAAHHNzaC1yc2EtY2VydC12MDFAb3BlbnNzaC5jb20=";
+ byte[] input = certLine.getBytes(StandardCharsets.UTF_8);
+ assertTrue(OpenSshCertificateAwareIdentityFile.isOpenSshCertificateFile(input));
+ }
+
+ @Test
+ public void testIsOpenSshCertificate_File_partialMatch() {
+ String partial = "ssh-rsa-cert-v01@openssh.co";
+ byte[] input = partial.getBytes(StandardCharsets.UTF_8);
+ assertFalse(OpenSshCertificateAwareIdentityFile.isOpenSshCertificateFile(input));
+ }
+
+ @Test
+ public void testIsOpenSshCertificate_File_extraCharactersInType() {
+ String extra = "ssh-rsax-cert-v01@openssh.com";
+ byte[] input = extra.getBytes(StandardCharsets.UTF_8);
+ assertFalse(OpenSshCertificateAwareIdentityFile.isOpenSshCertificateFile(input));
+ }
+
+ @Test
+ public void testIsOpenSshCertificate_File_casesensitive() {
+ String uppercase = "SSH-RSA-CERT-V01@OPENSSH.COM";
+ byte[] input = uppercase.getBytes(StandardCharsets.UTF_8);
+ assertFalse(OpenSshCertificateAwareIdentityFile.isOpenSshCertificateFile(input),
+ "Certificate type matching should be case-sensitive");
+ }
+
+ @Test
+ public void testIsOpenSshCertificate_File_minimumLengthBoundary() {
+ // Exactly 27 bytes - one less than minimum (28)
+ char[] chars = new char[27];
+ Arrays.fill(chars, 'a');
+ String justUnderMinimum = new String(chars);
+ byte[] input = justUnderMinimum.getBytes(StandardCharsets.UTF_8);
+ assertFalse(OpenSshCertificateAwareIdentityFile.isOpenSshCertificateFile(input));
+ }
+
+ @Test
+ public void testIsOpenSshCertificate_sshRsaWrongLengthFile() {
+ // ssh-rsa-cert-v01@openssh.com should be exactly 28 chars
+ String tooLong = "ssh-rsax-cert-v01@openssh.com";
+ byte[] input = tooLong.getBytes(StandardCharsets.UTF_8);
+ assertFalse(OpenSshCertificateAwareIdentityFile.isOpenSshCertificateFile(input));
+ }
+
+ @Test
+ public void testIsOpenSshCertificate_File_ecdsaWrongLength() {
+ // ecdsa cert types should be exactly 40 chars
+ String wrongLength = "ecdsa-sha2-nistp25-cert-v01@openssh.com"; // 39 chars
+ byte[] input = wrongLength.getBytes(StandardCharsets.UTF_8);
+ assertFalse(OpenSshCertificateAwareIdentityFile.isOpenSshCertificateFile(input));
+ }
+
+ @Test
+ public void testIsOpenSshCertificate_File_binaryData() {
+ // Binary data that might accidentally contain cert-like patterns
+ byte[] binary = new byte[] {0x00, 0x01, 0x02, 's', 's', 'h', '-', 'r', 's', 'a'};
+ assertFalse(OpenSshCertificateAwareIdentityFile.isOpenSshCertificateFile(binary));
+ }
+
+ @Test
+ public void testIsOpenSshCertificate_File_utf8Characters() {
+ // Certificate type with UTF-8 characters (should fail)
+ String utf8 = "ssh-rsá-cert-v01@openssh.com";
+ byte[] input = utf8.getBytes(StandardCharsets.UTF_8);
+ assertFalse(OpenSshCertificateAwareIdentityFile.isOpenSshCertificateFile(input));
+ }
+
+ @Test
+ public void testIsOpenSshCertificate_File_multilineWithCertOnSecondLine() {
+ // Simulate a file where the cert type is on the second line
+ String multiline =
+ "\nssh-rsa-cert-v01@openssh.com AAAAHHNzaC1yc2EtY2VydC12MDFAb3BlbnNzaC5jb20=";
+ byte[] input = multiline.getBytes(StandardCharsets.UTF_8);
+ assertTrue(OpenSshCertificateAwareIdentityFile.isOpenSshCertificateFile(input));
+ }
+
+ @Test
+ public void testIsOpenSshCertificate_File_onlyKeyTypeNoWhitespace() {
+ // All valid cert types without any trailing data or whitespace
+ String[] certTypes = {"ssh-rsa-cert-v01@openssh.com", "ssh-dss-cert-v01@openssh.com",
+ "ecdsa-sha2-nistp256-cert-v01@openssh.com", "ecdsa-sha2-nistp384-cert-v01@openssh.com",
+ "ecdsa-sha2-nistp521-cert-v01@openssh.com", "ssh-ed25519-cert-v01@openssh.com",
+ "ssh-ed448-cert-v01@openssh.com"};
+
+ for (String certType : certTypes) {
+ byte[] input = certType.getBytes(StandardCharsets.UTF_8);
+ assertTrue(OpenSshCertificateAwareIdentityFile.isOpenSshCertificateFile(input),
+ "Should recognize valid certificate type: " + certType);
+ }
+ }
+}
diff --git a/src/test/java/com/jcraft/jsch/OpenSshCertificateUtilTest.java b/src/test/java/com/jcraft/jsch/OpenSshCertificateUtilTest.java
new file mode 100644
index 000000000..313510e26
--- /dev/null
+++ b/src/test/java/com/jcraft/jsch/OpenSshCertificateUtilTest.java
@@ -0,0 +1,502 @@
+package com.jcraft.jsch;
+
+import static org.junit.jupiter.api.Assertions.assertArrayEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.nio.charset.StandardCharsets;
+import org.junit.jupiter.api.Test;
+
+public class OpenSshCertificateUtilTest {
+
+ @Test
+ public void testExtractSpaceDelimitedString_nullInput() {
+ assertNull(OpenSshCertificateUtil.extractSpaceDelimitedString(null, 0));
+ }
+
+ @Test
+ public void testExtractSpaceDelimitedString_emptyInput() {
+ byte[] empty = new byte[0];
+ assertNull(OpenSshCertificateUtil.extractSpaceDelimitedString(empty, 0));
+ }
+
+ @Test
+ public void testExtractSpaceDelimitedString_singleField() {
+ byte[] input = "field1".getBytes(StandardCharsets.UTF_8);
+ byte[] expected = "field1".getBytes(StandardCharsets.UTF_8);
+ assertArrayEquals(expected, OpenSshCertificateUtil.extractSpaceDelimitedString(input, 0));
+ }
+
+ @Test
+ public void testExtractSpaceDelimitedString_multipleFieldsExtractFirst() {
+ byte[] input = "field1 field2 field3".getBytes(StandardCharsets.UTF_8);
+ byte[] expected = "field1".getBytes(StandardCharsets.UTF_8);
+ assertArrayEquals(expected, OpenSshCertificateUtil.extractSpaceDelimitedString(input, 0));
+ }
+
+ @Test
+ public void testExtractSpaceDelimitedString_multipleFieldsExtractMiddle() {
+ byte[] input = "field1 field2 field3".getBytes(StandardCharsets.UTF_8);
+ byte[] expected = "field2".getBytes(StandardCharsets.UTF_8);
+ assertArrayEquals(expected, OpenSshCertificateUtil.extractSpaceDelimitedString(input, 1));
+ }
+
+ @Test
+ public void testExtractSpaceDelimitedString_multipleFieldsExtractLast() {
+ byte[] input = "field1 field2 field3".getBytes(StandardCharsets.UTF_8);
+ byte[] expected = "field3".getBytes(StandardCharsets.UTF_8);
+ assertArrayEquals(expected, OpenSshCertificateUtil.extractSpaceDelimitedString(input, 2));
+ }
+
+ @Test
+ public void testExtractSpaceDelimitedString_indexOutOfBounds() {
+ byte[] input = "field1 field2".getBytes(StandardCharsets.UTF_8);
+ assertNull(OpenSshCertificateUtil.extractSpaceDelimitedString(input, 5));
+ }
+
+ @Test
+ public void testExtractSpaceDelimitedString_negativeIndex() {
+ byte[] input = "field1 field2".getBytes(StandardCharsets.UTF_8);
+ assertNull(OpenSshCertificateUtil.extractSpaceDelimitedString(input, -1));
+ }
+
+ @Test
+ public void testExtractSpaceDelimitedString_leadingWhitespace() {
+ byte[] input = " field1 field2".getBytes(StandardCharsets.UTF_8);
+ byte[] expected = "field1".getBytes(StandardCharsets.UTF_8);
+ assertArrayEquals(expected, OpenSshCertificateUtil.extractSpaceDelimitedString(input, 0));
+ }
+
+ @Test
+ public void testExtractSpaceDelimitedString_trailingWhitespace() {
+ byte[] input = "field1 field2 ".getBytes(StandardCharsets.UTF_8);
+ byte[] expected = "field2".getBytes(StandardCharsets.UTF_8);
+ assertArrayEquals(expected, OpenSshCertificateUtil.extractSpaceDelimitedString(input, 1));
+ }
+
+ @Test
+ public void testExtractSpaceDelimitedString_multipleSpaces() {
+ byte[] input = "field1 field2 field3".getBytes(StandardCharsets.UTF_8);
+ byte[] expected = "field2".getBytes(StandardCharsets.UTF_8);
+ assertArrayEquals(expected, OpenSshCertificateUtil.extractSpaceDelimitedString(input, 1));
+ }
+
+ @Test
+ public void testExtractSpaceDelimitedString_tabDelimiter() {
+ byte[] input = "field1\tfield2\tfield3".getBytes(StandardCharsets.UTF_8);
+ byte[] expected = "field2".getBytes(StandardCharsets.UTF_8);
+ assertArrayEquals(expected, OpenSshCertificateUtil.extractSpaceDelimitedString(input, 1));
+ }
+
+ @Test
+ public void testExtractSpaceDelimitedString_mixedWhitespace() {
+ byte[] input = "field1 \t field2\t \tfield3".getBytes(StandardCharsets.UTF_8);
+ byte[] expected = "field2".getBytes(StandardCharsets.UTF_8);
+ assertArrayEquals(expected, OpenSshCertificateUtil.extractSpaceDelimitedString(input, 1));
+ }
+
+ @Test
+ public void testExtractSpaceDelimitedString_newlineDelimiter() {
+ byte[] input = "field1\nfield2\nfield3".getBytes(StandardCharsets.UTF_8);
+ byte[] expected = "field2".getBytes(StandardCharsets.UTF_8);
+ assertArrayEquals(expected, OpenSshCertificateUtil.extractSpaceDelimitedString(input, 1));
+ }
+
+ @Test
+ public void testExtractSpaceDelimitedString_carriageReturnDelimiter() {
+ byte[] input = "field1\rfield2\rfield3".getBytes(StandardCharsets.UTF_8);
+ byte[] expected = "field2".getBytes(StandardCharsets.UTF_8);
+ assertArrayEquals(expected, OpenSshCertificateUtil.extractSpaceDelimitedString(input, 1));
+ }
+
+ @Test
+ public void testExtractSpaceDelimitedString_mixedLineEndings() {
+ byte[] input = "field1\r\nfield2\n\rfield3".getBytes(StandardCharsets.UTF_8);
+ byte[] expected = "field2".getBytes(StandardCharsets.UTF_8);
+ assertArrayEquals(expected, OpenSshCertificateUtil.extractSpaceDelimitedString(input, 1));
+ }
+
+ @Test
+ public void testExtractSpaceDelimitedString_onlyWhitespace() {
+ byte[] input = " \t\n\r ".getBytes(StandardCharsets.UTF_8);
+ assertNull(OpenSshCertificateUtil.extractSpaceDelimitedString(input, 0));
+ }
+
+ @Test
+ public void testExtractSpaceDelimitedString_singleFieldWithWhitespace() {
+ byte[] input = " \t field1 \n\r ".getBytes(StandardCharsets.UTF_8);
+ byte[] expected = "field1".getBytes(StandardCharsets.UTF_8);
+ assertArrayEquals(expected, OpenSshCertificateUtil.extractSpaceDelimitedString(input, 0));
+ }
+
+ @Test
+ public void testExtractSpaceDelimitedString_realWorldCertificate() {
+ // Simulating a typical OpenSSH certificate line format
+ String certLine =
+ "ssh-rsa-cert-v01@openssh.com AAAAHHNzaC1yc2EtY2VydC12MDFAb3BlbnNzaC5jb20= user@host";
+ byte[] input = certLine.getBytes(StandardCharsets.UTF_8);
+
+ byte[] expectedKeyType = "ssh-rsa-cert-v01@openssh.com".getBytes(StandardCharsets.UTF_8);
+ byte[] expectedKeyData =
+ "AAAAHHNzaC1yc2EtY2VydC12MDFAb3BlbnNzaC5jb20=".getBytes(StandardCharsets.UTF_8);
+ byte[] expectedComment = "user@host".getBytes(StandardCharsets.UTF_8);
+
+ assertArrayEquals(expectedKeyType,
+ OpenSshCertificateUtil.extractSpaceDelimitedString(input, 0));
+ assertArrayEquals(expectedKeyData,
+ OpenSshCertificateUtil.extractSpaceDelimitedString(input, 1));
+ assertArrayEquals(expectedComment,
+ OpenSshCertificateUtil.extractSpaceDelimitedString(input, 2));
+ }
+
+
+ @Test
+ public void testExtractSpaceDelimitedString_lastFieldNoTrailingWhitespace() {
+ byte[] input = "field1 field2".getBytes(StandardCharsets.UTF_8);
+ byte[] expected = "field2".getBytes(StandardCharsets.UTF_8);
+ assertArrayEquals(expected, OpenSshCertificateUtil.extractSpaceDelimitedString(input, 1));
+ }
+
+ @Test
+ public void testExtractSpaceDelimitedString_twoFields() {
+ byte[] input = "key data".getBytes(StandardCharsets.UTF_8);
+ byte[] expectedFirst = "key".getBytes(StandardCharsets.UTF_8);
+ byte[] expectedSecond = "data".getBytes(StandardCharsets.UTF_8);
+
+ assertArrayEquals(expectedFirst, OpenSshCertificateUtil.extractSpaceDelimitedString(input, 0));
+ assertArrayEquals(expectedSecond, OpenSshCertificateUtil.extractSpaceDelimitedString(input, 1));
+ assertNull(OpenSshCertificateUtil.extractSpaceDelimitedString(input, 2));
+ }
+
+ @Test
+ public void testExtractSpaceDelimitedString_specialCharactersInFields() {
+ byte[] input = "field-1 field_2 field.3@domain".getBytes(StandardCharsets.UTF_8);
+ byte[] expected = "field.3@domain".getBytes(StandardCharsets.UTF_8);
+ assertArrayEquals(expected, OpenSshCertificateUtil.extractSpaceDelimitedString(input, 2));
+ }
+
+ // ==================== Tests for isCertificateSignedByTrustedCA ====================
+
+ /**
+ * Test that isCertificateSignedByTrustedCA returns true when a matching, non-revoked CA is found.
+ */
+ @Test
+ public void testIsCertificateSignedByTrustedCA_trustedCAFound() throws Exception {
+ // Setup
+ JSch jsch = new JSch();
+ KnownHosts knownHosts = new KnownHosts(jsch);
+ String host = "public.example.com";
+ String base64CaPublicKey = "AAAAB3NzaC1yc2EAAAADAQABAAABAQ==";
+
+ // Create a matching CA host key
+ byte[] keyBytes =
+ Util.fromBase64(Util.str2byte(base64CaPublicKey), 0, base64CaPublicKey.length());
+ HostKey caHostKey =
+ new HostKey("@cert-authority", "*.example.com", HostKey.SSHRSA, keyBytes, null);
+
+ knownHosts.add(caHostKey, null);
+
+ // Execute
+ boolean result =
+ OpenSshCertificateUtil.isCertificateSignedByTrustedCA(knownHosts, host, base64CaPublicKey);
+
+ // Verify
+ assertTrue(result, "Should return true when trusted CA is found");
+ }
+
+ /**
+ * Test that isCertificateSignedByTrustedCA returns false when the CA public key doesn't match.
+ */
+ @Test
+ public void testIsCertificateSignedByTrustedCA_caKeyMismatch() throws Exception {
+ // Setup
+ JSch jsch = new JSch();
+ KnownHosts knownHosts = new KnownHosts(jsch);
+ String host = "example.com";
+ String base64CaPublicKey = "AAAAB3NzaC1yc2EAAAADAQABAAABAQ==";
+ String differentCaKey = "AAAAB3NzaC1yc2EAAAADAQABAAABDIFFERENT==";
+
+ // Create a CA with different key
+ byte[] keyBytes = Util.fromBase64(Util.str2byte(differentCaKey), 0, differentCaKey.length());
+ HostKey caHostKey =
+ new HostKey("@cert-authority", "*.example.com", HostKey.SSHRSA, keyBytes, null);
+
+ knownHosts.add(caHostKey, null);
+
+ // Execute
+ boolean result =
+ OpenSshCertificateUtil.isCertificateSignedByTrustedCA(knownHosts, host, base64CaPublicKey);
+
+ // Verify
+ assertFalse(result, "Should return false when CA key doesn't match");
+ }
+
+ /**
+ * Test that isCertificateSignedByTrustedCA returns false when the CA is revoked.
+ */
+ @Test
+ public void testIsCertificateSignedByTrustedCA_caIsRevoked() throws Exception {
+ // Setup
+ JSch jsch = new JSch();
+ KnownHosts knownHosts = new KnownHosts(jsch);
+ String host = "example.com";
+ String base64CaPublicKey = "AAAAB3NzaC1yc2EAAAADAQABAAABAQ==";
+
+ byte[] keyBytes =
+ Util.fromBase64(Util.str2byte(base64CaPublicKey), 0, base64CaPublicKey.length());
+
+ // Create a @cert-authority entry
+ HostKey caHostKey =
+ new HostKey("@cert-authority", "*.example.com", HostKey.SSHRSA, keyBytes, null);
+
+ // Create a @revoked entry with the same key
+ HostKey revokedHostKey =
+ new HostKey("@revoked", "*.example.com", HostKey.SSHRSA, keyBytes, null);
+
+ knownHosts.add(caHostKey, null);
+ knownHosts.add(revokedHostKey, null);
+
+ // Execute
+ boolean result =
+ OpenSshCertificateUtil.isCertificateSignedByTrustedCA(knownHosts, host, base64CaPublicKey);
+
+ // Verify
+ assertFalse(result, "Should return false when CA is revoked (fail-closed security)");
+ }
+
+ /**
+ * Test that isCertificateSignedByTrustedCA returns false when host pattern doesn't match.
+ */
+ @Test
+ public void testIsCertificateSignedByTrustedCA_hostPatternMismatch() throws Exception {
+ // Setup
+ JSch jsch = new JSch();
+ KnownHosts knownHosts = new KnownHosts(jsch);
+ String host = "different.com";
+ String base64CaPublicKey = "AAAAB3NzaC1yc2EAAAADAQABAAABAQ==";
+
+ byte[] keyBytes =
+ Util.fromBase64(Util.str2byte(base64CaPublicKey), 0, base64CaPublicKey.length());
+ HostKey caHostKey =
+ new HostKey("@cert-authority", "*.example.com", HostKey.SSHRSA, keyBytes, null);
+
+ knownHosts.add(caHostKey, null);
+
+ // Execute
+ boolean result =
+ OpenSshCertificateUtil.isCertificateSignedByTrustedCA(knownHosts, host, base64CaPublicKey);
+
+ // Verify
+ assertFalse(result, "Should return false when host pattern doesn't match");
+ }
+
+ /**
+ * Test that isCertificateSignedByTrustedCA returns false when repository is empty.
+ */
+ @Test
+ public void testIsCertificateSignedByTrustedCA_emptyRepository() throws JSchException {
+ // Setup
+ JSch jsch = new JSch();
+ KnownHosts knownHosts = new KnownHosts(jsch);
+ String host = "example.com";
+ String base64CaPublicKey = "AAAAB3NzaC1yc2EAAAADAQABAAABAQ==";
+
+ // Execute
+ boolean result =
+ OpenSshCertificateUtil.isCertificateSignedByTrustedCA(knownHosts, host, base64CaPublicKey);
+
+ // Verify
+ assertFalse(result, "Should return false when repository is empty");
+ }
+
+ /**
+ * Test that isCertificateSignedByTrustedCA succeeds when there are multiple CAs and one matches.
+ */
+ @Test
+ public void testIsCertificateSignedByTrustedCA_multipleCAsOneMatches() throws Exception {
+ // Setup
+ JSch jsch = new JSch();
+ KnownHosts knownHosts = new KnownHosts(jsch);
+ String host = "this.example.com";
+ String base64CaPublicKey = "AAAAB3NzaC1yc2EAAAADAQABAAABAQ==";
+ String differentCaKey = "AAAAB3NzaC1yc2EAAAADAQABAAABDIFFERENT==";
+
+ byte[] matchingKeyBytes =
+ Util.fromBase64(Util.str2byte(base64CaPublicKey), 0, base64CaPublicKey.length());
+ byte[] differentKeyBytes =
+ Util.fromBase64(Util.str2byte(differentCaKey), 0, differentCaKey.length());
+
+ // Create multiple CA entries - one matches, one doesn't
+ HostKey caHostKey1 =
+ new HostKey("@cert-authority", "*.test.com", HostKey.SSHRSA, differentKeyBytes, null);
+ HostKey caHostKey2 =
+ new HostKey("@cert-authority", "*.example.com", HostKey.SSHRSA, matchingKeyBytes, null);
+
+ knownHosts.add(caHostKey1, null);
+ knownHosts.add(caHostKey2, null);
+
+ // Execute
+ boolean result =
+ OpenSshCertificateUtil.isCertificateSignedByTrustedCA(knownHosts, host, base64CaPublicKey);
+
+ // Verify
+ assertTrue(result, "Should return true when one of multiple CAs matches (anyMatch behavior)");
+ }
+
+ /**
+ * Test that isCertificateSignedByTrustedCA ignores non-@cert-authority entries.
+ */
+ @Test
+ public void testIsCertificateSignedByTrustedCA_ignoresNonCaEntries() throws Exception {
+ // Setup
+ JSch jsch = new JSch();
+ KnownHosts knownHosts = new KnownHosts(jsch);
+ String host = "example.com";
+ String base64CaPublicKey = "AAAAB3NzaC1yc2EAAAADAQABAAABAQ==";
+
+ byte[] keyBytes =
+ Util.fromBase64(Util.str2byte(base64CaPublicKey), 0, base64CaPublicKey.length());
+
+ // Create regular host key (no @cert-authority marker)
+ HostKey regularHostKey = new HostKey("", "example.com", HostKey.SSHRSA, keyBytes, null);
+
+ // Create @revoked entry
+ HostKey revokedHostKey = new HostKey("@revoked", "example.com", HostKey.SSHRSA, keyBytes, null);
+
+ knownHosts.add(regularHostKey, null);
+ knownHosts.add(revokedHostKey, null);
+
+ // Execute
+ boolean result =
+ OpenSshCertificateUtil.isCertificateSignedByTrustedCA(knownHosts, host, base64CaPublicKey);
+
+ // Verify
+ assertFalse(result, "Should ignore entries without @cert-authority marker");
+ }
+
+ /**
+ * Test that isCertificateSignedByTrustedCA handles wildcard host patterns correctly.
+ */
+ @Test
+ public void testIsCertificateSignedByTrustedCA_wildcardHostPattern() throws Exception {
+ // Setup
+ JSch jsch = new JSch();
+ KnownHosts knownHosts = new KnownHosts(jsch);
+ String host = "sub.example.com";
+ String base64CaPublicKey = "AAAAB3NzaC1yc2EAAAADAQABAAABAQ==";
+
+ byte[] keyBytes =
+ Util.fromBase64(Util.str2byte(base64CaPublicKey), 0, base64CaPublicKey.length());
+
+ // Create CA with wildcard pattern
+ HostKey caHostKey =
+ new HostKey("@cert-authority", "*.example.com", HostKey.SSHRSA, keyBytes, null);
+
+ knownHosts.add(caHostKey, null);
+
+ // Execute
+ boolean result =
+ OpenSshCertificateUtil.isCertificateSignedByTrustedCA(knownHosts, host, base64CaPublicKey);
+
+ // Verify
+ assertTrue(result, "Should match wildcard host pattern *.example.com with sub.example.com");
+ }
+
+ /**
+ * Test that isCertificateSignedByTrustedCA handles different key types (Ed25519).
+ */
+ @Test
+ public void testIsCertificateSignedByTrustedCA_ed25519KeyType() throws Exception {
+ // Setup
+ JSch jsch = new JSch();
+ KnownHosts knownHosts = new KnownHosts(jsch);
+ String host = "example.com";
+ String base64CaPublicKey = "AAAAC3NzaC1lZDI1NTE5AAAAI==";
+
+ byte[] keyBytes =
+ Util.fromBase64(Util.str2byte(base64CaPublicKey), 0, base64CaPublicKey.length());
+
+ HostKey caHostKey =
+ new HostKey("@cert-authority", "*example.com", HostKey.ED25519, keyBytes, null);
+
+ knownHosts.add(caHostKey, null);
+
+ // Execute
+ boolean result =
+ OpenSshCertificateUtil.isCertificateSignedByTrustedCA(knownHosts, host, base64CaPublicKey);
+
+ // Verify
+ assertTrue(result, "Should work with Ed25519 key type");
+ }
+
+ /**
+ * Test that isCertificateSignedByTrustedCA correctly handles the scenario where a CA exists but
+ * with a null key field.
+ */
+ @Test
+ public void testIsCertificateSignedByTrustedCA_caWithNullKey() throws Exception {
+ // Setup
+ JSch jsch = new JSch();
+ KnownHosts knownHosts = new KnownHosts(jsch);
+ String host = "example.com";
+ String base64CaPublicKey = "AAAAB3NzaC1yc2EAAAADAQABAAABAQ==";
+
+ // Create CA with null key (malformed entry)
+ HostKey caHostKeyWithNullKey =
+ new HostKey("@cert-authority", "*.example.com", HostKey.SSHRSA, null, null);
+
+ knownHosts.add(caHostKeyWithNullKey, null);
+
+ // Execute
+ boolean result =
+ OpenSshCertificateUtil.isCertificateSignedByTrustedCA(knownHosts, host, base64CaPublicKey);
+
+ // Verify
+ assertFalse(result, "Should return false when CA has null key (fail-closed security)");
+ }
+
+ /**
+ * Test complex scenario: multiple CAs, some revoked, some with different keys, one valid match.
+ */
+ @Test
+ public void testIsCertificateSignedByTrustedCA_complexScenario() throws Exception {
+ // Setup
+ JSch jsch = new JSch();
+ KnownHosts knownHosts = new KnownHosts(jsch);
+ String host = "prod.example.com";
+ String validCaKey = "AAAAB3NzaC1yc2EVALIDKEY==";
+ String revokedCaKey = "AAAAB3NzaC1yc2EREVOKEDKEY==";
+ String differentCaKey = "AAAAB3NzaC1yc2EDIFFERENTKEY==";
+
+ byte[] validKeyBytes = Util.fromBase64(Util.str2byte(validCaKey), 0, validCaKey.length());
+ byte[] revokedKeyBytes = Util.fromBase64(Util.str2byte(revokedCaKey), 0, revokedCaKey.length());
+ byte[] differentKeyBytes =
+ Util.fromBase64(Util.str2byte(differentCaKey), 0, differentCaKey.length());
+
+ // Create multiple entries
+ HostKey validCa =
+ new HostKey("@cert-authority", "*.example.com", HostKey.SSHRSA, validKeyBytes, null);
+ HostKey revokedCa =
+ new HostKey("@cert-authority", "*.example.com", HostKey.SSHRSA, revokedKeyBytes, null);
+ HostKey revokedMarker =
+ new HostKey("@revoked", "*.example.com", HostKey.SSHRSA, revokedKeyBytes, null);
+ HostKey differentCa =
+ new HostKey("@cert-authority", "*.test.com", HostKey.SSHRSA, differentKeyBytes, null);
+ HostKey regularHost = new HostKey("", "prod.example.com", HostKey.SSHRSA, validKeyBytes, null);
+
+ knownHosts.add(differentCa, null);
+ knownHosts.add(revokedCa, null);
+ knownHosts.add(revokedMarker, null);
+ knownHosts.add(validCa, null);
+ knownHosts.add(regularHost, null);
+
+ // Execute
+ boolean result =
+ OpenSshCertificateUtil.isCertificateSignedByTrustedCA(knownHosts, host, validCaKey);
+
+ // Verify
+ assertTrue(result,
+ "Should find the valid CA among multiple entries, ignoring revoked/different/null entries");
+ }
+}
diff --git a/src/test/java/com/jcraft/jsch/UserCertAuthIT.java b/src/test/java/com/jcraft/jsch/UserCertAuthIT.java
index 6bdf018ce..c8407fcec 100644
--- a/src/test/java/com/jcraft/jsch/UserCertAuthIT.java
+++ b/src/test/java/com/jcraft/jsch/UserCertAuthIT.java
@@ -1,9 +1,17 @@
package com.jcraft.jsch;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Paths;
+import java.util.Arrays;
+import java.util.Base64;
+import java.util.List;
+import java.util.Locale;
import com.github.valfirst.slf4jtest.LoggingEvent;
import com.github.valfirst.slf4jtest.TestLogger;
import com.github.valfirst.slf4jtest.TestLoggerFactory;
import org.apache.commons.codec.digest.DigestUtils;
+import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.MethodSource;
import org.slf4j.Logger;
@@ -14,17 +22,6 @@
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
-import java.nio.file.Files;
-import java.nio.file.Paths;
-import java.util.Arrays;
-import java.util.Base64;
-import java.util.List;
-import java.util.Locale;
-
-import static com.jcraft.jsch.ResourceUtil.getResourceFile;
-import static java.nio.charset.StandardCharsets.UTF_8;
-import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
-import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* Integrated Test (IT) class to verify the JSch public key authentication mechanism using **OpenSSH
@@ -117,11 +114,12 @@ public static Iterable extends String> privateKeyParams() {
@MethodSource("privateKeyParams")
@ParameterizedTest(name = "key: {0}, cert: {0}-cert.pub")
public void opensshCertificateParserTest(String privateKey) throws Exception {
- HostKey hostKey =
- readHostKey(getResourceFile(this.getClass(), "certificates/docker/ssh_host_rsa_key.pub"));
+ HostKey hostKey = readHostKey(
+ ResourceUtil.getResourceFile(this.getClass(), "certificates/docker/ssh_host_rsa_key.pub"));
JSch ssh = new JSch();
- ssh.addIdentity(getResourceFile(this.getClass(), "certificates/" + privateKey),
- getResourceFile(this.getClass(), "certificates/" + privateKey + "-cert.pub"), null);
+ ssh.addIdentity(ResourceUtil.getResourceFile(this.getClass(), "certificates/" + privateKey),
+ ResourceUtil.getResourceFile(this.getClass(), "certificates/" + privateKey + "-cert.pub"),
+ null);
ssh.getHostKeyRepository().add(hostKey, null);
Session session = ssh.getSession("root", sshd.getHost(), sshd.getFirstMappedPort());
@@ -144,7 +142,7 @@ public void opensshCertificateParserTest(String privateKey) throws Exception {
* @throws Exception if the file cannot be read or the key content is malformed.
*/
private HostKey readHostKey(String fileName) throws Exception {
- List lines = Files.readAllLines(Paths.get(fileName), UTF_8);
+ List lines = Files.readAllLines(Paths.get(fileName), StandardCharsets.UTF_8);
String[] split = lines.get(0).split("\\s+");
String hostname = String.format(Locale.ROOT, "[%s]:%d", "localhost", 2222);
return new HostKey(hostname, Base64.getDecoder().decode(split[1]));
@@ -160,13 +158,13 @@ private HostKey readHostKey(String fileName) throws Exception {
* @throws Exception if connection or SFTP channel setup fails.
*/
private void doSftp(Session session) throws Exception {
- assertDoesNotThrow(() -> {
+ Assertions.assertDoesNotThrow(() -> {
try {
session.setTimeout(timeout);
session.connect();
ChannelSftp sftp = (ChannelSftp) session.openChannel("sftp");
sftp.connect(timeout);
- assertTrue(sftp.isConnected());
+ Assertions.assertTrue(sftp.isConnected());
sftp.disconnect();
session.disconnect();
} catch (Exception e) {
diff --git a/src/test/resources/certificates/host/Dockerfile b/src/test/resources/certificates/host/Dockerfile
index 68464ea8b..18be3fd72 100644
--- a/src/test/resources/certificates/host/Dockerfile
+++ b/src/test/resources/certificates/host/Dockerfile
@@ -1,10 +1,8 @@
FROM alpine:3.16
RUN apk add --update openssh openssh-server bash && rm /var/cache/apk/*
-RUN adduser -D luigi
-RUN echo 'luigi:passwordLuigi' | chpasswd
-RUN mkdir -p /home/luigi/.ssh
+RUN mkdir -p /root/.ssh
COPY ["sshd_config","/etc/ssh/sshd_config"]
-COPY ["authorized_keys","/home/luigi/.ssh/authorized_keys"]
+COPY ["authorized_keys","/root/.ssh/authorized_keys"]
COPY ["entrypoint.sh","/entrypoint.sh"]
COPY ["ssh_host_rsa_key","/etc/ssh/ssh_host_rsa_key"]
COPY ["ssh_host_rsa_key-cert.pub","/etc/ssh/ssh_host_rsa_key-cert.pub"]
From 0cf60d9e40eb7098380d42490a4c5f93f8f0e405 Mon Sep 17 00:00:00 2001
From: Luigi De Masi
Date: Thu, 15 Jan 2026 20:18:24 +0100
Subject: [PATCH 6/6] Add support for OpenSSH certificates, resolve #31 - Fixes
after code review for Host Certificate support - part3
---
src/main/java/com/jcraft/jsch/DHECN.java | 11 +-
src/main/java/com/jcraft/jsch/DHECNKEM.java | 11 +-
src/main/java/com/jcraft/jsch/DHGEX.java | 18 +-
src/main/java/com/jcraft/jsch/DHGN.java | 10 +-
src/main/java/com/jcraft/jsch/DHXEC.java | 11 +-
src/main/java/com/jcraft/jsch/DHXECKEM.java | 10 +-
src/main/java/com/jcraft/jsch/JSch.java | 47 +-
.../java/com/jcraft/jsch/KeyExchange.java | 46 +-
.../java/com/jcraft/jsch/OpenSSHConfig.java | 4 +-
.../com/jcraft/jsch/OpenSshCertificate.java | 53 ++-
.../OpenSshCertificateAwareIdentityFile.java | 105 ++--
.../jcraft/jsch/OpenSshCertificateBuffer.java | 11 +-
.../OpenSshCertificateHostKeyVerifier.java | 109 ++---
.../jsch/OpenSshCertificateKeyTypes.java | 161 +++++++
.../jcraft/jsch/OpenSshCertificateParser.java | 79 ++-
.../jcraft/jsch/OpenSshCertificateUtil.java | 357 ++++++++------
src/main/java/com/jcraft/jsch/Session.java | 78 ++-
.../com/jcraft/jsch/SignatureWrapper.java | 16 +-
.../com/jcraft/jsch/UserAuthPublicKey.java | 39 +-
.../com/jcraft/jsch/HostCertificateIT.java | 22 +-
.../com/jcraft/jsch/JSchAddIdentityTest.java | 136 ++++++
.../java/com/jcraft/jsch/KeyExchangeTest.java | 2 +-
.../jsch/OpenSshCertificateBufferTest.java | 205 ++++++++
...OpenSshCertificateHostKeyVerifierTest.java | 171 +++++++
.../jsch/OpenSshCertificateKeyTypesTest.java | 146 ++++++
.../jsch/OpenSshCertificateUtilTest.java | 448 +++++++++++++++++-
.../java/com/jcraft/jsch/SessionTest.java | 75 +++
.../java/com/jcraft/jsch/UserCertAuthIT.java | 9 +-
28 files changed, 1940 insertions(+), 450 deletions(-)
create mode 100644 src/main/java/com/jcraft/jsch/OpenSshCertificateKeyTypes.java
create mode 100644 src/test/java/com/jcraft/jsch/JSchAddIdentityTest.java
create mode 100644 src/test/java/com/jcraft/jsch/OpenSshCertificateBufferTest.java
create mode 100644 src/test/java/com/jcraft/jsch/OpenSshCertificateHostKeyVerifierTest.java
create mode 100644 src/test/java/com/jcraft/jsch/OpenSshCertificateKeyTypesTest.java
diff --git a/src/main/java/com/jcraft/jsch/DHECN.java b/src/main/java/com/jcraft/jsch/DHECN.java
index 6e0963bec..726c8f3f0 100644
--- a/src/main/java/com/jcraft/jsch/DHECN.java
+++ b/src/main/java/com/jcraft/jsch/DHECN.java
@@ -98,7 +98,7 @@ public void init(Session session, byte[] V_S, byte[] V_C, byte[] I_S, byte[] I_C
}
@Override
- public boolean doNext(Buffer _buf, int sshMessageType) throws Exception {
+ public boolean next(Buffer _buf) throws Exception {
int i, j;
switch (state) {
case SSH_MSG_KEX_ECDH_REPLY:
@@ -107,11 +107,12 @@ public boolean doNext(Buffer _buf, int sshMessageType) throws Exception {
// string K_S, server's public host key
// string Q_S, server's ephemeral public key octet string
// string the signature on the exchange hash
-
- if (sshMessageType != SSH_MSG_KEX_ECDH_REPLY) {
+ j = _buf.getInt();
+ j = _buf.getByte();
+ j = _buf.getByte();
+ if (j != SSH_MSG_KEX_ECDH_REPLY) {
if (session.getLogger().isEnabled(Logger.ERROR)) {
- session.getLogger().log(Logger.ERROR,
- "type: must be SSH_MSG_KEX_ECDH_REPLY " + sshMessageType);
+ session.getLogger().log(Logger.ERROR, "type: must be SSH_MSG_KEX_ECDH_REPLY " + j);
}
return false;
}
diff --git a/src/main/java/com/jcraft/jsch/DHECNKEM.java b/src/main/java/com/jcraft/jsch/DHECNKEM.java
index 8023cf401..362c32d6e 100644
--- a/src/main/java/com/jcraft/jsch/DHECNKEM.java
+++ b/src/main/java/com/jcraft/jsch/DHECNKEM.java
@@ -113,7 +113,7 @@ public void init(Session session, byte[] V_S, byte[] V_C, byte[] I_S, byte[] I_C
}
@Override
- public boolean doNext(Buffer _buf, int sshMessageType) throws Exception {
+ public boolean next(Buffer _buf) throws Exception {
int i, j;
switch (state) {
case SSH_MSG_KEX_HYBRID_REPLY:
@@ -122,11 +122,12 @@ public boolean doNext(Buffer _buf, int sshMessageType) throws Exception {
// string K_S, server's public host key
// string S_REPLY
// string the signature on the exchange hash
-
- if (sshMessageType != SSH_MSG_KEX_HYBRID_REPLY) {
+ j = _buf.getInt();
+ j = _buf.getByte();
+ j = _buf.getByte();
+ if (j != SSH_MSG_KEX_HYBRID_REPLY) {
if (session.getLogger().isEnabled(Logger.ERROR)) {
- session.getLogger().log(Logger.ERROR,
- "type: must be SSH_MSG_KEX_HYBRID_REPLY " + sshMessageType);
+ session.getLogger().log(Logger.ERROR, "type: must be SSH_MSG_KEX_HYBRID_REPLY " + j);
}
return false;
}
diff --git a/src/main/java/com/jcraft/jsch/DHGEX.java b/src/main/java/com/jcraft/jsch/DHGEX.java
index 6d0c47649..cefe3b3cd 100644
--- a/src/main/java/com/jcraft/jsch/DHGEX.java
+++ b/src/main/java/com/jcraft/jsch/DHGEX.java
@@ -108,17 +108,19 @@ public void init(Session session, byte[] V_S, byte[] V_C, byte[] I_S, byte[] I_C
}
@Override
- public boolean doNext(Buffer _buf, int sshMessageType) throws Exception {
+ public boolean next(Buffer _buf) throws Exception {
int i, j;
switch (state) {
case SSH_MSG_KEX_DH_GEX_GROUP:
// byte SSH_MSG_KEX_DH_GEX_GROUP(31)
// mpint p, safe prime
// mpint g, generator for subgroup in GF (p)
- if (sshMessageType != SSH_MSG_KEX_DH_GEX_GROUP) {
+ _buf.getInt();
+ _buf.getByte();
+ j = _buf.getByte();
+ if (j != SSH_MSG_KEX_DH_GEX_GROUP) {
if (session.getLogger().isEnabled(Logger.ERROR)) {
- session.getLogger().log(Logger.ERROR,
- "type: must be SSH_MSG_KEX_DH_GEX_GROUP " + sshMessageType);
+ session.getLogger().log(Logger.ERROR, "type: must be SSH_MSG_KEX_DH_GEX_GROUP " + j);
}
return false;
}
@@ -160,10 +162,12 @@ public boolean doNext(Buffer _buf, int sshMessageType) throws Exception {
// string server public host key and certificates (K_S)
// mpint f
// string signature of H
- if (sshMessageType != SSH_MSG_KEX_DH_GEX_REPLY) {
+ j = _buf.getInt();
+ j = _buf.getByte();
+ j = _buf.getByte();
+ if (j != SSH_MSG_KEX_DH_GEX_REPLY) {
if (session.getLogger().isEnabled(Logger.ERROR)) {
- session.getLogger().log(Logger.ERROR,
- "type: must be SSH_MSG_KEX_DH_GEX_REPLY " + sshMessageType);
+ session.getLogger().log(Logger.ERROR, "type: must be SSH_MSG_KEX_DH_GEX_REPLY " + j);
}
return false;
}
diff --git a/src/main/java/com/jcraft/jsch/DHGN.java b/src/main/java/com/jcraft/jsch/DHGN.java
index e8b0296a0..e94c200fd 100644
--- a/src/main/java/com/jcraft/jsch/DHGN.java
+++ b/src/main/java/com/jcraft/jsch/DHGN.java
@@ -105,7 +105,7 @@ public void init(Session session, byte[] V_S, byte[] V_C, byte[] I_S, byte[] I_C
}
@Override
- public boolean doNext(Buffer _buf, int sshMessageType) throws Exception {
+ public boolean next(Buffer _buf) throws Exception {
int i, j;
switch (state) {
@@ -115,10 +115,12 @@ public boolean doNext(Buffer _buf, int sshMessageType) throws Exception {
// string server public host key and certificates (K_S)
// mpint f
// string signature of H
- if (sshMessageType != SSH_MSG_KEXDH_REPLY) {
+ j = _buf.getInt();
+ j = _buf.getByte();
+ j = _buf.getByte();
+ if (j != SSH_MSG_KEXDH_REPLY) {
if (session.getLogger().isEnabled(Logger.ERROR)) {
- session.getLogger().log(Logger.ERROR,
- "type: must be SSH_MSG_KEXDH_REPLY " + sshMessageType);
+ session.getLogger().log(Logger.ERROR, "type: must be SSH_MSG_KEXDH_REPLY " + j);
}
return false;
}
diff --git a/src/main/java/com/jcraft/jsch/DHXEC.java b/src/main/java/com/jcraft/jsch/DHXEC.java
index e885ec449..c9a807dbd 100644
--- a/src/main/java/com/jcraft/jsch/DHXEC.java
+++ b/src/main/java/com/jcraft/jsch/DHXEC.java
@@ -98,7 +98,7 @@ public void init(Session session, byte[] V_S, byte[] V_C, byte[] I_S, byte[] I_C
}
@Override
- public boolean doNext(Buffer _buf, int sshMessageType) throws Exception {
+ public boolean next(Buffer _buf) throws Exception {
int i, j;
switch (state) {
case SSH_MSG_KEX_ECDH_REPLY:
@@ -107,11 +107,12 @@ public boolean doNext(Buffer _buf, int sshMessageType) throws Exception {
// string K_S, server's public host key
// string Q_S, server's ephemeral public key octet string
// string the signature on the exchange hash
-
- if (sshMessageType != SSH_MSG_KEX_ECDH_REPLY) {
+ j = _buf.getInt();
+ j = _buf.getByte();
+ j = _buf.getByte();
+ if (j != SSH_MSG_KEX_ECDH_REPLY) {
if (session.getLogger().isEnabled(Logger.ERROR)) {
- session.getLogger().log(Logger.ERROR,
- "type: must be SSH_MSG_KEX_ECDH_REPLY " + sshMessageType);
+ session.getLogger().log(Logger.ERROR, "type: must be SSH_MSG_KEX_ECDH_REPLY " + j);
}
return false;
}
diff --git a/src/main/java/com/jcraft/jsch/DHXECKEM.java b/src/main/java/com/jcraft/jsch/DHXECKEM.java
index 8d2b83664..93f2cbb44 100644
--- a/src/main/java/com/jcraft/jsch/DHXECKEM.java
+++ b/src/main/java/com/jcraft/jsch/DHXECKEM.java
@@ -112,7 +112,7 @@ public void init(Session session, byte[] V_S, byte[] V_C, byte[] I_S, byte[] I_C
}
@Override
- public boolean doNext(Buffer _buf, int sshMessageType) throws Exception {
+ public boolean next(Buffer _buf) throws Exception {
int i, j;
switch (state) {
case SSH_MSG_KEX_ECDH_REPLY:
@@ -121,10 +121,12 @@ public boolean doNext(Buffer _buf, int sshMessageType) throws Exception {
// string K_S, server's public host key
// string Q_S, server's ephemeral public key octet string
// string the signature on the exchange hash
- if (sshMessageType != SSH_MSG_KEX_ECDH_REPLY) {
+ j = _buf.getInt();
+ j = _buf.getByte();
+ j = _buf.getByte();
+ if (j != SSH_MSG_KEX_ECDH_REPLY) {
if (session.getLogger().isEnabled(Logger.ERROR)) {
- session.getLogger().log(Logger.ERROR,
- "type: must be SSH_MSG_KEX_ECDH_REPLY " + sshMessageType);
+ session.getLogger().log(Logger.ERROR, "type: must be SSH_MSG_KEX_ECDH_REPLY " + j);
}
return false;
}
diff --git a/src/main/java/com/jcraft/jsch/JSch.java b/src/main/java/com/jcraft/jsch/JSch.java
index e2b2df7bb..826973495 100644
--- a/src/main/java/com/jcraft/jsch/JSch.java
+++ b/src/main/java/com/jcraft/jsch/JSch.java
@@ -26,6 +26,7 @@
package com.jcraft.jsch;
+import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.util.Collections;
@@ -39,13 +40,19 @@ public class JSch {
/** The version number. */
public static final String VERSION = Version.getVersion();
+ private static final String CERTIFICATE_FILENAME_SUFFIX = "-cert.pub";
+
static Hashtable config = new Hashtable<>();
static {
config.put("kex", Util.getSystemProperty("jsch.kex",
"mlkem768x25519-sha256,curve25519-sha256,curve25519-sha256@libssh.org,ecdh-sha2-nistp256,ecdh-sha2-nistp384,ecdh-sha2-nistp521,diffie-hellman-group-exchange-sha256,diffie-hellman-group16-sha512,diffie-hellman-group18-sha512,diffie-hellman-group14-sha256"));
config.put("server_host_key", Util.getSystemProperty("jsch.server_host_key",
- "ssh-ed25519-cert-v01@openssh.com,ecdsa-sha2-nistp256-cert-v01@openssh.com,ecdsa-sha2-nistp384-cert-v01@openssh.com,ecdsa-sha2-nistp521-cert-v01@openssh.com,ssh-rsa-cert-v01@openssh.com,ssh-dss-cert-v01@openssh.com,ssh-ed25519,ecdsa-sha2-nistp256,ecdsa-sha2-nistp384,ecdsa-sha2-nistp521,rsa-sha2-512,rsa-sha2-256"));
+ "ssh-ed25519-cert-v01@openssh.com,ecdsa-sha2-nistp256-cert-v01@openssh.com,ecdsa-sha2-nistp384-cert-v01@openssh.com,ecdsa-sha2-nistp521-cert-v01@openssh.com,ssh-ed25519,ecdsa-sha2-nistp256,ecdsa-sha2-nistp384,ecdsa-sha2-nistp521,rsa-sha2-512,rsa-sha2-256"));
+ // CASignatureAlgorithms: specifies which algorithms are allowed for signing of certificates
+ // by certificate authorities (CAs). Default matches OpenSSH 8.2+ (excludes ssh-rsa/SHA-1).
+ config.put("ca_signature_algorithms", Util.getSystemProperty("jsch.ca_signature_algorithms",
+ "ssh-ed25519,ecdsa-sha2-nistp256,ecdsa-sha2-nistp384,ecdsa-sha2-nistp521,rsa-sha2-512,rsa-sha2-256"));
config.put("prefer_known_host_key_types",
Util.getSystemProperty("jsch.prefer_known_host_key_types", "yes"));
config.put("enable_strict_kex", Util.getSystemProperty("jsch.enable_strict_kex", "yes"));
@@ -240,7 +247,7 @@ public class JSch {
config.put("PreferredAuthentications", Util.getSystemProperty("jsch.preferred_authentications",
"gssapi-with-mic,publickey,keyboard-interactive,password"));
config.put("PubkeyAcceptedAlgorithms", Util.getSystemProperty("jsch.client_pubkey",
- "ssh-ed25519-cert-v01@openssh.com,ecdsa-sha2-nistp256-cert-v01@openssh.com,ecdsa-sha2-nistp384-cert-v01@openssh.com,ecdsa-sha2-nistp521-cert-v01@openssh.com,ssh-rsa-cert-v01@openssh.com,ssh-dss-cert-v01@openssh.com,ssh-ed25519,ecdsa-sha2-nistp256,ecdsa-sha2-nistp384,ecdsa-sha2-nistp521,rsa-sha2-512,rsa-sha2-256"));
+ "ssh-ed25519-cert-v01@openssh.com,ecdsa-sha2-nistp256-cert-v01@openssh.com,ecdsa-sha2-nistp384-cert-v01@openssh.com,ecdsa-sha2-nistp521-cert-v01@openssh.com,ssh-ed25519,ecdsa-sha2-nistp256,ecdsa-sha2-nistp384,ecdsa-sha2-nistp521,rsa-sha2-512,rsa-sha2-256"));
config.put("enable_pubkey_auth_query",
Util.getSystemProperty("jsch.enable_pubkey_auth_query", "yes"));
config.put("try_additional_pubkey_algorithms",
@@ -260,9 +267,15 @@ public class JSch {
config.put("MaxAuthTries", Util.getSystemProperty("jsch.max_auth_tries", "6"));
config.put("ClearAllForwardings", "no");
- config.put("ClearAllKeys", "no");
- config.put("HostCertificateToKeyFallback",
- Util.getSystemProperty("jsch.host_certificate_to_key_fallback", "no"));
+ /*
+ * host_certificate_to_key_fallback: Controls behavior when host certificate validation fails. -
+ * "yes" (default): Fall back to standard public key verification using the certificate's
+ * embedded public key. This matches OpenSSH behavior, which always performs this fallback. -
+ * "no": Reject connection if certificate validation fails (more secure, but may break existing
+ * setups when upgrading to a JSch version with certificate support).
+ */
+ config.put("host_certificate_to_key_fallback",
+ Util.getSystemProperty("jsch.host_certificate_to_key_fallback", "yes"));
}
final InstanceLogger instLogger = new InstanceLogger();
@@ -497,18 +510,34 @@ public void addIdentity(String prvkey, byte[] passphrase) throws JSchException {
*/
public void addIdentity(String prvkey, String pubkey, byte[] passphrase) throws JSchException {
byte[] pubkeyFileContent = null;
+ String pubkeyFile = pubkey;
Identity identity;
- if (pubkey != null) {
+ // If pubkey is null, try to auto-discover certificate file (prvkey + "-cert.pub")
+ // This mimics KeyPair.load() behavior which tries prvkey + ".pub"
+ if (pubkeyFile == null) {
+ String certFile = prvkey + CERTIFICATE_FILENAME_SUFFIX;
+ if (new File(certFile).exists()) {
+ pubkeyFile = certFile;
+ }
+ }
+
+ if (pubkeyFile != null) {
try {
- pubkeyFileContent = Util.fromFile(pubkey);
+ pubkeyFileContent = Util.fromFile(pubkeyFile);
} catch (IOException e) {
- throw new JSchException(e.toString(), e);
+ // Only throw if pubkey was explicitly provided (not auto-discovered)
+ // This matches KeyPair.load() behavior
+ if (pubkey != null) {
+ throw new JSchException(e.toString(), e);
+ }
+ // Otherwise, silently ignore and fall through to IdentityFile
}
}
+
if (pubkeyFileContent != null
&& OpenSshCertificateAwareIdentityFile.isOpenSshCertificateFile(pubkeyFileContent)) {
- identity = OpenSshCertificateAwareIdentityFile.newInstance(prvkey, pubkey, instLogger);
+ identity = OpenSshCertificateAwareIdentityFile.newInstance(prvkey, pubkeyFile, instLogger);
} else {
identity = IdentityFile.newInstance(prvkey, pubkey, instLogger);
}
diff --git a/src/main/java/com/jcraft/jsch/KeyExchange.java b/src/main/java/com/jcraft/jsch/KeyExchange.java
index 3fdc222fa..6a441f63a 100644
--- a/src/main/java/com/jcraft/jsch/KeyExchange.java
+++ b/src/main/java/com/jcraft/jsch/KeyExchange.java
@@ -69,16 +69,11 @@ public abstract class KeyExchange {
protected byte[] H = null;
protected byte[] K_S = null;
protected OpenSshCertificate hostKeyCertificate = null;
- protected boolean isOpenSshServerHostKeyType = false;
OpenSshCertificate getHostKeyCertificate() {
return hostKeyCertificate;
}
- boolean isOpenSshServerHostKeyType() {
- return isOpenSshServerHostKeyType;
- }
-
public abstract void init(Session session, byte[] V_S, byte[] V_C, byte[] I_S, byte[] I_C)
throws Exception;
@@ -87,30 +82,7 @@ void doInit(Session session, byte[] V_S, byte[] V_C, byte[] I_S, byte[] I_C) thr
init(session, V_S, V_C, I_S, I_C);
}
- public boolean next(Buffer buf) throws Exception {
-
- /* @formatter:off
- * After decryption and decompression, the start of the buffer looks like this:
- * 1. Packet Length (4 bytes): An integer specifying the length of the data that follows (from the padding length
- * byte to the end of the padding).
- * 2. Padding Length (1 byte): A single byte specifying how many bytes of random padding are at the end of the packet.
- * 3. SSH Message Type (1 byte): This is the byte you're looking for, which identifies the message (e.g., SSH_MSG_CHANNEL_DATA).
- * 4. Message-Specific Data (n bytes): The rest of the payload.
- * @formatter:on
- */
-
- // skip packet length (4 bytes) and padding length(1 byte)
- buf.readSkip(5);
- // get the message type
- int sshMessageType = buf.getByte();
-
- return doNext(buf, sshMessageType);
- }
-
-
- protected boolean doNext(Buffer buf, int sshMessageType) throws Exception {
- throw new IllegalStateException("this should never be called!");
- }
+ public abstract boolean next(Buffer buf) throws Exception;
public abstract int getState();
@@ -231,10 +203,6 @@ protected static String[] guess(Session session, byte[] I_S, byte[] I_C) throws
}
public String getFingerPrint() {
- return getFingerPrint(getHostKey());
- }
-
- public String getFingerPrint(byte[] key) {
HASH hash = null;
try {
String _c = session.getConfig("FingerprintHash").toLowerCase(Locale.ROOT);
@@ -245,7 +213,7 @@ public String getFingerPrint(byte[] key) {
session.getLogger().log(Logger.ERROR, "getFingerPrint: " + e.getMessage(), e);
}
}
- return Util.getFingerPrint(hash, key, true, false);
+ return Util.getFingerPrint(hash, getHostKey(), true, false);
}
byte[] getK() {
@@ -327,7 +295,6 @@ protected byte[] normalize(byte[] secret) {
return foo;
}
-
/**
* Verifies the cryptographic signature of the SSH key exchange hash.
*
@@ -411,12 +378,9 @@ protected byte[] normalize(byte[] secret) {
protected boolean verify(String alg, byte[] K_S, int index, byte[] sig_of_H) throws Exception {
int i, j;
boolean result = false;
- OpenSshCertificateBuffer buffer = new OpenSshCertificateBuffer(K_S);
- buffer.s = index;
i = index;
- if (OpenSshCertificateAwareIdentityFile.isOpenSshCertificateKeyType(alg)) {
- this.isOpenSshServerHostKeyType = true;
+ if (OpenSshCertificateKeyTypes.isCertificateKeyType(alg)) {
OpenSshCertificate certificate = OpenSshCertificateParser.parse(session.jsch.instLogger, K_S);
// Certificates used for host authentication MUST have "certificate role" of
@@ -427,6 +391,10 @@ protected boolean verify(String alg, byte[] K_S, int index, byte[] sig_of_H) thr
+ "': user certificate presented for host authentication. " + "Host: " + session.host);
}
K_S = certificate.getCertificatePublicKey();
+ if (K_S == null) {
+ throw new JSchException(
+ "Invalid certificate '" + certificate.getId() + "': missing public key");
+ }
// Extract algorithm from certificate public key
i = 0;
diff --git a/src/main/java/com/jcraft/jsch/OpenSSHConfig.java b/src/main/java/com/jcraft/jsch/OpenSSHConfig.java
index a66a04c7c..1a5dea34a 100644
--- a/src/main/java/com/jcraft/jsch/OpenSSHConfig.java
+++ b/src/main/java/com/jcraft/jsch/OpenSSHConfig.java
@@ -71,6 +71,7 @@
*
LocalForward
* RemoteForward
* ClearAllForwardings
+ * CASignatureAlgorithms
*
*
* @see ConfigRepository
@@ -79,7 +80,7 @@ public class OpenSSHConfig implements ConfigRepository {
private static final Set keysWithListAdoption = Stream
.of("KexAlgorithms", "Ciphers", "HostKeyAlgorithms", "MACs", "PubkeyAcceptedAlgorithms",
- "PubkeyAcceptedKeyTypes")
+ "PubkeyAcceptedKeyTypes", "CASignatureAlgorithms")
.map(string -> string.toUpperCase(Locale.ROOT)).collect(Collectors.toSet());
/**
@@ -173,6 +174,7 @@ static Hashtable getKeymap() {
keymap.put("compression.c2s", "Compression");
keymap.put("compression_level", "CompressionLevel");
keymap.put("MaxAuthTries", "NumberOfPasswordPrompts");
+ keymap.put("ca_signature_algorithms", "CASignatureAlgorithms");
}
class MyConfig implements Config {
diff --git a/src/main/java/com/jcraft/jsch/OpenSshCertificate.java b/src/main/java/com/jcraft/jsch/OpenSshCertificate.java
index cfac5d59b..7a9571db4 100644
--- a/src/main/java/com/jcraft/jsch/OpenSshCertificate.java
+++ b/src/main/java/com/jcraft/jsch/OpenSshCertificate.java
@@ -1,6 +1,7 @@
package com.jcraft.jsch;
import java.util.Collection;
+import java.util.Collections;
import java.util.Map;
/**
@@ -29,7 +30,7 @@ class OpenSshCertificate {
static final int SSH2_CERT_TYPE_USER = 1;
/**
- * Certificate type constant for user certificates
+ * Certificate type constant for host certificates
*/
static final int SSH2_CERT_TYPE_HOST = 2;
@@ -140,11 +141,11 @@ String getKeyType() {
}
byte[] getNonce() {
- return nonce;
+ return nonce == null ? null : nonce.clone();
}
byte[] getCertificatePublicKey() {
- return certificatePublicKey;
+ return certificatePublicKey == null ? null : certificatePublicKey.clone();
}
long getSerial() {
@@ -160,7 +161,7 @@ String getId() {
}
Collection getPrincipals() {
- return principals;
+ return principals == null ? null : Collections.unmodifiableCollection(principals);
}
long getValidAfter() {
@@ -172,11 +173,11 @@ long getValidBefore() {
}
Map getCriticalOptions() {
- return criticalOptions;
+ return criticalOptions == null ? null : Collections.unmodifiableMap(criticalOptions);
}
Map getExtensions() {
- return extensions;
+ return extensions == null ? null : Collections.unmodifiableMap(extensions);
}
String getReserved() {
@@ -184,11 +185,11 @@ String getReserved() {
}
byte[] getSignatureKey() {
- return signatureKey;
+ return signatureKey == null ? null : signatureKey.clone();
}
byte[] getSignature() {
- return signature;
+ return signature == null ? null : signature.clone();
}
boolean isUserCertificate() {
@@ -204,7 +205,7 @@ boolean isValidNow() {
}
byte[] getMessage() {
- return message;
+ return message == null ? null : message.clone();
}
/**
@@ -308,10 +309,42 @@ Builder message(byte[] message) {
* Constructs and returns an immutable OpenSshCertificate instance.
*
* @return A new, immutable OpenSshCertificate object.
+ * @throws IllegalStateException if any required field is missing or invalid.
*/
OpenSshCertificate build() {
- // You could add validation logic here if needed (e.g., check for null required fields)
+ validate();
return new OpenSshCertificate(this);
}
+
+ /**
+ * Validates that all required fields are present and valid.
+ *
+ * @throws IllegalStateException if any required field is missing or invalid.
+ */
+ private void validate() {
+ if (keyType == null || keyType.trim().isEmpty()) {
+ throw new IllegalStateException("keyType is required and cannot be null or empty");
+ }
+ if (nonce == null || nonce.length == 0) {
+ throw new IllegalStateException("nonce is required and cannot be null or empty");
+ }
+ if (certificatePublicKey == null || certificatePublicKey.length == 0) {
+ throw new IllegalStateException(
+ "certificatePublicKey is required and cannot be null or empty");
+ }
+ if (type != SSH2_CERT_TYPE_USER && type != SSH2_CERT_TYPE_HOST) {
+ throw new IllegalStateException(
+ "type must be SSH2_CERT_TYPE_USER (1) or SSH2_CERT_TYPE_HOST (2), got: " + type);
+ }
+ if (signatureKey == null || signatureKey.length == 0) {
+ throw new IllegalStateException("signatureKey is required and cannot be null or empty");
+ }
+ if (signature == null || signature.length == 0) {
+ throw new IllegalStateException("signature is required and cannot be null or empty");
+ }
+ if (message == null || message.length == 0) {
+ throw new IllegalStateException("message is required and cannot be null or empty");
+ }
+ }
}
}
diff --git a/src/main/java/com/jcraft/jsch/OpenSshCertificateAwareIdentityFile.java b/src/main/java/com/jcraft/jsch/OpenSshCertificateAwareIdentityFile.java
index d2f8d6a80..755f7f357 100644
--- a/src/main/java/com/jcraft/jsch/OpenSshCertificateAwareIdentityFile.java
+++ b/src/main/java/com/jcraft/jsch/OpenSshCertificateAwareIdentityFile.java
@@ -19,22 +19,14 @@
*/
class OpenSshCertificateAwareIdentityFile implements Identity {
- static final String SSH_RSA_CERT_V01_AT_OPENSSH_DOT_COM = "ssh-rsa-cert-v01@openssh.com";
-
- static final String SSH_DSS_CERT_V01_AT_OPENSSH_DOT_COM = "ssh-dss-cert-v01@openssh.com";
-
- static final String ECDSA_SHA2_NISTP256_CERT_V01_AT_OPENSSH_DOT_COM =
- "ecdsa-sha2-nistp256-cert-v01@openssh.com";
-
- static final String ECDSA_SHA2_NISTP384_CERT_V01_AT_OPENSSH_DOT_COM =
- "ecdsa-sha2-nistp384-cert-v01@openssh.com";
-
- static final String ECDSA_SHA2_NISTP521_CERT_V01_AT_OPENSSH_DOT_COM =
- "ecdsa-sha2-nistp521-cert-v01@openssh.com";
-
- static final String SSH_ED25519_CERT_V01_AT_OPENSSH_DOT_COM = "ssh-ed25519-cert-v01@openssh.com";
-
- static final String SSH_ED448_CERT_V01_AT_OPENSSH_DOT_COM = "ssh-ed448-cert-v01@openssh.com";
+ /**
+ * Maximum expected length for a key type string. Used as a sanity check to quickly reject
+ * obviously invalid data before performing string conversion. The longest current certificate key
+ * type is 41 characters (ecdsa-sha2-nistp521-cert-v01@openssh.com), so 100 provides sufficient
+ * headroom for potential future key types while still being small enough for an effective
+ * early-exit check.
+ */
+ static final int MAX_KEY_TYPE_LENGTH = 100;
/**
* parsed certificate.
@@ -83,46 +75,14 @@ static boolean isOpenSshCertificateFile(byte[] certificateFileContent) {
OpenSshCertificateUtil.extractSpaceDelimitedString(certificateFileContent, 0);
// avoid converting byte array to string if the keyType is clearly not a supported certificate
- if (keyTypeBytes == null || keyTypeBytes.length == 0 || keyTypeBytes.length > 100) {
+ if (keyTypeBytes == null || keyTypeBytes.length == 0
+ || keyTypeBytes.length > MAX_KEY_TYPE_LENGTH) {
return false;
}
String keyType = new String(keyTypeBytes, StandardCharsets.UTF_8);
- return isOpenSshCertificateKeyType(keyType);
-
- /*
- * switch(keyType){ case SSH_RSA_CERT_V01_AT_OPENSSH_DOT_COM: case
- * SSH_DSS_CERT_V01_AT_OPENSSH_DOT_COM: case ECDSA_SHA2_NISTP256_CERT_V01_AT_OPENSSH_DOT_COM:
- * case ECDSA_SHA2_NISTP384_CERT_V01_AT_OPENSSH_DOT_COM: case
- * ECDSA_SHA2_NISTP521_CERT_V01_AT_OPENSSH_DOT_COM: case
- * SSH_ED25519_CERT_V01_AT_OPENSSH_DOT_COM: case SSH_ED448_CERT_V01_AT_OPENSSH_DOT_COM: return
- * true; default: return false; }
- */
- }
-
-
-
- /**
- * Determines if the given key type represents an OpenSSH certificate.
- *
- * @param publicKeyType the key type string to check
- * @return {@code true} if the type is a supported OpenSSH certificate type, {@code false}
- * otherwise
- */
- static boolean isOpenSshCertificateKeyType(String publicKeyType) {
- switch (publicKeyType) {
- case SSH_RSA_CERT_V01_AT_OPENSSH_DOT_COM:
- case SSH_DSS_CERT_V01_AT_OPENSSH_DOT_COM:
- case ECDSA_SHA2_NISTP256_CERT_V01_AT_OPENSSH_DOT_COM:
- case ECDSA_SHA2_NISTP384_CERT_V01_AT_OPENSSH_DOT_COM:
- case ECDSA_SHA2_NISTP521_CERT_V01_AT_OPENSSH_DOT_COM:
- case SSH_ED25519_CERT_V01_AT_OPENSSH_DOT_COM:
- case SSH_ED448_CERT_V01_AT_OPENSSH_DOT_COM:
- return true;
- default:
- return false;
- }
+ return OpenSshCertificateKeyTypes.isCertificateKeyType(keyType);
}
/**
@@ -165,19 +125,27 @@ static Identity newInstance(String name, byte[] prvkey, byte[] certificateFileCo
KeyPair kpair;
byte[] declaredKeyTypeBytes;
byte[] commentBytes;
- byte[] base64KeyDataBytes;
+ byte[] keyData;
String declaredKeyType;
String comment;
try {
declaredKeyTypeBytes = OpenSshCertificateUtil.extractKeyType(certificateFileContentBytes);
- base64KeyDataBytes = OpenSshCertificateUtil.extractKeyData(certificateFileContentBytes);
+ if (declaredKeyTypeBytes == null || declaredKeyTypeBytes.length == 0) {
+ throw new JSchException("Invalid certificate file: missing or empty key type");
+ }
+ byte[] base64KeyDataBytes =
+ OpenSshCertificateUtil.extractKeyData(certificateFileContentBytes);
+ if (base64KeyDataBytes == null || base64KeyDataBytes.length == 0) {
+ throw new JSchException("Invalid certificate file: missing or empty key data");
+ }
commentBytes = OpenSshCertificateUtil.extractComment(certificateFileContentBytes);
- byte[] keyData = Util.fromBase64(base64KeyDataBytes, 0, base64KeyDataBytes.length);
+
+ keyData = Util.fromBase64(base64KeyDataBytes, 0, base64KeyDataBytes.length);
cert = OpenSshCertificateParser.parse(instLogger, keyData);
declaredKeyType = Util.byte2str(declaredKeyTypeBytes, StandardCharsets.UTF_8);
- comment = Util.byte2str(commentBytes, StandardCharsets.UTF_8);
+ comment = commentBytes != null ? Util.byte2str(commentBytes, StandardCharsets.UTF_8) : null;
// keyType
if (OpenSshCertificateUtil.isEmpty(cert.getKeyType())
@@ -195,13 +163,22 @@ static Identity newInstance(String name, byte[] prvkey, byte[] certificateFileCo
}
certPublicKey = cert.getCertificatePublicKey();
+ if (certPublicKey == null) {
+ throw new JSchException("Invalid certificate: missing public key");
+ }
kpair = KeyPair.load(instLogger, prvkey, certPublicKey);
- } catch (Exception e) {
- throw new JSchException(e.toString(), e);
+ } catch (JSchException e) {
+ throw e;
+ } catch (IllegalArgumentException e) {
+ throw new JSchException("Invalid certificate format: " + e.getMessage(), e);
+ } catch (IllegalStateException e) {
+ throw new JSchException("Invalid certificate data: " + e.getMessage(), e);
+ } catch (RuntimeException e) {
+ throw new JSchException("Unexpected error parsing certificate: " + e.getMessage(), e);
}
- return new OpenSshCertificateAwareIdentityFile(name, declaredKeyType, base64KeyDataBytes, cert,
- kpair, comment);
+ return new OpenSshCertificateAwareIdentityFile(name, declaredKeyType, keyData, cert, kpair,
+ comment);
}
/**
@@ -209,18 +186,19 @@ static Identity newInstance(String name, byte[] prvkey, byte[] certificateFileCo
*
* @param name the identity name
* @param keyType the key type declared in the certificate file
+ * @param publicKeyBlob the decoded public key blob (certificate in binary form)
* @param certificate the parsed certificate
* @param kpair the key pair containing the private key
* @param comment the optional comment from the certificate file
*/
- private OpenSshCertificateAwareIdentityFile(String name, String keyType, byte[] base64KeyData,
- OpenSshCertificate certificate, KeyPair kpair, String comment) throws JSchException {
+ private OpenSshCertificateAwareIdentityFile(String name, String keyType, byte[] publicKeyBlob,
+ OpenSshCertificate certificate, KeyPair kpair, String comment) {
this.identity = name;
this.certificate = certificate;
this.kpair = kpair;
this.comment = comment;
this.keyType = keyType;
- this.publicKeyBlob = Util.fromBase64(base64KeyData, 0, base64KeyData.length);
+ this.publicKeyBlob = publicKeyBlob;
}
@Override
@@ -241,7 +219,8 @@ public byte[] getSignature(byte[] data) {
@Override
public byte[] getSignature(byte[] data, String alg) {
String rawKeyType = OpenSshCertificateUtil.getRawKeyType(keyType);
- return kpair.getSignature(data, rawKeyType);
+ // Fall back to keyType if rawKeyType is null (defensive check)
+ return kpair.getSignature(data, rawKeyType != null ? rawKeyType : keyType);
}
@Override
diff --git a/src/main/java/com/jcraft/jsch/OpenSshCertificateBuffer.java b/src/main/java/com/jcraft/jsch/OpenSshCertificateBuffer.java
index fd2536b94..19c599208 100644
--- a/src/main/java/com/jcraft/jsch/OpenSshCertificateBuffer.java
+++ b/src/main/java/com/jcraft/jsch/OpenSshCertificateBuffer.java
@@ -23,8 +23,6 @@
*/
class OpenSshCertificateBuffer extends Buffer {
- private static final byte[] EMPTY_BYTE_ARRAY = {};
-
/**
* Creates a new OpenSSH certificate buffer from decoded certificate bytes.
*
@@ -41,9 +39,18 @@ class OpenSshCertificateBuffer extends Buffer {
* Reads a length-prefixed byte array from the buffer.
*
* @return the byte array data
+ * @throws IllegalArgumentException if the length prefix is negative or exceeds available data
*/
byte[] getBytes() {
int reqLen = getInt();
+ if (reqLen < 0) {
+ throw new IllegalArgumentException(
+ "Invalid length in certificate data: negative length " + reqLen);
+ }
+ if (reqLen > getLength()) {
+ throw new IllegalArgumentException("Invalid length in certificate data: requested " + reqLen
+ + " bytes but only " + getLength() + " available");
+ }
byte[] b = new byte[reqLen];
getByte(b);
return b;
diff --git a/src/main/java/com/jcraft/jsch/OpenSshCertificateHostKeyVerifier.java b/src/main/java/com/jcraft/jsch/OpenSshCertificateHostKeyVerifier.java
index c6d89287e..14951dd60 100644
--- a/src/main/java/com/jcraft/jsch/OpenSshCertificateHostKeyVerifier.java
+++ b/src/main/java/com/jcraft/jsch/OpenSshCertificateHostKeyVerifier.java
@@ -1,10 +1,7 @@
package com.jcraft.jsch;
-import java.util.Arrays;
import java.util.Collection;
-import java.util.HashSet;
-import java.util.Set;
-import java.util.stream.Collectors;
+import java.util.Locale;
/**
* A verifier for OpenSSH host certificates.
@@ -42,14 +39,14 @@ static void checkHostCertificate(Session session, OpenSshCertificate certificate
byte[] caPublicKeyByteArray = certificate.getSignatureKey();
- String base64CaPublicKey =
- Util.byte2str(Util.toBase64(caPublicKeyByteArray, 0, caPublicKeyByteArray.length, true));
-
String host = session.host;
+ if (host == null) {
+ throw new JSchException("Cannot verify host certificate: session host is null");
+ }
HostKeyRepository repository = session.getHostKeyRepository();
- boolean caFound =
- OpenSshCertificateUtil.isCertificateSignedByTrustedCA(repository, host, base64CaPublicKey);
+ boolean caFound = OpenSshCertificateUtil.isCertificateSignedByTrustedCA(repository, host,
+ caPublicKeyByteArray);
if (!caFound) {
throw new JSchUnknownCAKeyException("Rejected certificate '" + certificate.getId() + "': "
@@ -60,29 +57,32 @@ static void checkHostCertificate(Session session, OpenSshCertificate certificate
String caPublicKeyAlgorithm = Util.byte2str(caPublicKeyBuffer.getString());
String certificateId = certificate.getId();
- // check if the certificate is a
+ // check if this is a Host certificate
if (!certificate.isHostCertificate()) {
- throw new JSchInvalidHostCertificateException("reject HostKey: certificate id='"
+ throw new JSchInvalidHostCertificateException("rejected HostKey: certificate id='"
+ certificateId + "' is not a host certificate. Host:" + host);
}
if (!certificate.isValidNow()) {
throw new JSchInvalidHostCertificateException(
- "rejected HostKey: signature verification failed, " + "certificate expired for id:"
+ "rejected HostKey: certificate not valid (expired or not yet valid) for id:"
+ certificateId);
}
checkSignature(certificate, caPublicKeyAlgorithm, session);
- // "As a special case, a zero-length "valid principals" field means the certificate is valid for
- // any principal of the specified type."
- // Empty principals in a host certificate mean the certificate is valid for any host.
Collection principals = certificate.getPrincipals();
- if (principals != null && !principals.isEmpty()) {
- if (!principals.contains(host)) {
- throw new JSchException("rejected HostKey: invalid principal '" + host
- + "', allowed principals: " + principals);
- }
+ if (principals == null || principals.isEmpty()) {
+ throw new JSchException("rejected HostKey: invalid principal '" + host
+ + "', allowed principals list is null or empty.");
+ }
+
+ // Convert host to lowercase for principal matching (same as OpenSSH ssh_login())
+ String principalHost = host.toLowerCase(Locale.ROOT);
+
+ if (!principals.contains(principalHost)) {
+ throw new JSchException("rejected HostKey: invalid principal '" + principalHost
+ + "', allowed principals: " + principals);
}
if (!OpenSshCertificateUtil.isEmpty(certificate.getCriticalOptions())) {
@@ -92,60 +92,6 @@ static void checkHostCertificate(Session session, OpenSshCertificate certificate
}
}
- /**
- * Parses a raw public key byte array into its constituent mathematical components.
- *
- * Different public key algorithms (RSA, DSS, ECDSA, etc.) have different structures. This method
- * decodes the algorithm-specific format and returns the components needed for cryptographic
- * operations.
- *
- *
- * @param certificatePublicKey the raw byte array of the public key blob.
- * @return A 2D byte array where each inner array is a component of the public key (e.g., for RSA,
- * it returns {exponent, modulus}).
- * @throws JSchException if the public key algorithm is unknown or the key format is corrupt.
- */
- static byte[][] parsePublicKey(byte[] certificatePublicKey) throws JSchException {
- Buffer buffer = new Buffer(certificatePublicKey);
- String algorithm = Util.byte2str(buffer.getString());
-
- if (algorithm.startsWith("ssh-rsa") || algorithm.startsWith("rsa-")) {
- byte[] ee = buffer.getMPInt();
- byte[] n = buffer.getMPInt();
- return new byte[][] {ee, n};
- }
-
- if (algorithm.startsWith("ssh-dss")) {
- byte[] p = buffer.getMPInt();
- byte[] q = buffer.getMPInt();
- byte[] g = buffer.getMPInt();
- byte[] y = buffer.getMPInt();
- return new byte[][] {y, p, q, g};
- }
-
- if (algorithm.startsWith("ecdsa-sha2-")) {
- // https://www.rfc-editor.org/rfc/rfc5656#section-3.1
- // The string [identifier] is the identifier of the elliptic curve domain parameters.
- String identifier = Util.byte2str(buffer.getString());
- int len = buffer.getInt();
- int x04 = buffer.getByte();
- byte[] r = new byte[(len - 1) / 2];
- byte[] s = new byte[(len - 1) / 2];
- buffer.getByte(r);
- buffer.getByte(s);
- return new byte[][] {r, s};
- }
-
- if (algorithm.startsWith("ssh-ed25519") || algorithm.startsWith("ssh-ed448")) {
- int keyLength = buffer.getInt();
- byte[] edXXX_pub_array = new byte[keyLength];
- buffer.getByte(edXXX_pub_array);
- return new byte[][] {edXXX_pub_array};
- }
- throw new JSchUnknownPublicKeyAlgorithmException(
- "Unknown algorithm '" + algorithm.trim() + "'");
- }
-
/**
* Verifies the cryptographic signature of the certificate.
*
@@ -162,7 +108,8 @@ static void checkSignature(OpenSshCertificate certificate, String caPublicKeyAlg
Session session) throws JSchException {
// Check signature
SignatureWrapper signature = getSignatureWrapper(certificate, caPublicKeyAlgorithm, session);
- byte[][] publicKey = parsePublicKey(certificate.getSignatureKey());
+ byte[][] publicKey =
+ OpenSshCertificateUtil.parsePublicKeyComponents(certificate.getSignatureKey());
boolean verified;
try {
signature.init();
@@ -183,14 +130,17 @@ static void checkSignature(OpenSshCertificate certificate, String caPublicKeyAlg
* Creates and validates a {@link SignatureWrapper} for the certificate.
*
* This helper method extracts the signature algorithm from the certificate and verifies that it
- * matches the algorithm of the signing CA's key.
+ * matches the algorithm of the signing CA's key. It also validates that the signature algorithm
+ * is allowed and available according to the {@code ca_signature_algorithms} configuration.
*
*
* @param certificate the OpenSSH certificate.
* @param caPublicKeyAlgorithm the expected public key algorithm of the CA.
+ * @param session the current session.
* @return a configured {@link SignatureWrapper} instance.
- * @throws JSchException if the signature algorithm does not match the CA's key algorithm, or if
- * the wrapper cannot be instantiated.
+ * @throws JSchException if the signature algorithm does not match the CA's key algorithm, is not
+ * in the allowed CA signature algorithms list, is not available at runtime, or if the
+ * wrapper cannot be instantiated.
*/
static SignatureWrapper getSignatureWrapper(OpenSshCertificate certificate,
String caPublicKeyAlgorithm, Session session) throws JSchException {
@@ -204,6 +154,9 @@ static SignatureWrapper getSignatureWrapper(OpenSshCertificate certificate,
+ signatureAlgorithm + "' - CA public Key algorithm: '" + caPublicKeyAlgorithm + "'");
}
+ // Validate that the CA signature algorithm is allowed and available at runtime
+ session.checkCASignatureAlgorithm(signatureAlgorithm);
+
return new SignatureWrapper(signatureAlgorithm, session);
}
diff --git a/src/main/java/com/jcraft/jsch/OpenSshCertificateKeyTypes.java b/src/main/java/com/jcraft/jsch/OpenSshCertificateKeyTypes.java
new file mode 100644
index 000000000..7f5b8b3e6
--- /dev/null
+++ b/src/main/java/com/jcraft/jsch/OpenSshCertificateKeyTypes.java
@@ -0,0 +1,161 @@
+package com.jcraft.jsch;
+
+/**
+ * Constants for OpenSSH certificate key type identifiers.
+ *
+ * This class provides a centralized location for all OpenSSH certificate key type strings as
+ * defined in the OpenSSH certificate protocol. These constants are used throughout JSch for
+ * identifying and handling certificate-based authentication.
+ *
+ *
+ *
+ * Certificate key types follow the naming convention: {@code -cert-v01@openssh.com}
+ *
+ *
+ * @see OpenSSH Certificate
+ * Protocol
+ */
+final class OpenSshCertificateKeyTypes {
+
+ /**
+ * RSA certificate key type using SHA-1 for the host key signature.
+ */
+ static final String SSH_RSA_CERT_V01 = "ssh-rsa-cert-v01@openssh.com";
+
+ /**
+ * RSA certificate key type using SHA-256 for the host key signature.
+ */
+ static final String RSA_SHA2_256_CERT_V01 = "rsa-sha2-256-cert-v01@openssh.com";
+
+ /**
+ * RSA certificate key type using SHA-512 for the host key signature.
+ */
+ static final String RSA_SHA2_512_CERT_V01 = "rsa-sha2-512-cert-v01@openssh.com";
+
+ /**
+ * DSA (DSS) certificate key type.
+ */
+ static final String SSH_DSS_CERT_V01 = "ssh-dss-cert-v01@openssh.com";
+
+ /**
+ * ECDSA certificate key type using NIST P-256 curve.
+ */
+ static final String ECDSA_SHA2_NISTP256_CERT_V01 = "ecdsa-sha2-nistp256-cert-v01@openssh.com";
+
+ /**
+ * ECDSA certificate key type using NIST P-384 curve.
+ */
+ static final String ECDSA_SHA2_NISTP384_CERT_V01 = "ecdsa-sha2-nistp384-cert-v01@openssh.com";
+
+ /**
+ * ECDSA certificate key type using NIST P-521 curve.
+ */
+ static final String ECDSA_SHA2_NISTP521_CERT_V01 = "ecdsa-sha2-nistp521-cert-v01@openssh.com";
+
+ /**
+ * Ed25519 certificate key type.
+ */
+ static final String SSH_ED25519_CERT_V01 = "ssh-ed25519-cert-v01@openssh.com";
+
+ /**
+ * Ed448 certificate key type.
+ */
+ static final String SSH_ED448_CERT_V01 = "ssh-ed448-cert-v01@openssh.com";
+
+ /**
+ * Suffix used for all OpenSSH certificate key types.
+ */
+ static final String CERT_SUFFIX = "-cert-v01@openssh.com";
+
+ /**
+ * Private constructor to prevent instantiation.
+ */
+ private OpenSshCertificateKeyTypes() {
+ // Utility class - do not instantiate
+ }
+
+ /**
+ * Checks if the given key type string represents an OpenSSH certificate.
+ *
+ * @param keyType the key type string to check
+ * @return {@code true} if the key type is a supported OpenSSH certificate type, {@code false}
+ * otherwise
+ */
+ static boolean isCertificateKeyType(String keyType) {
+ if (keyType == null) {
+ return false;
+ }
+ switch (keyType) {
+ case SSH_RSA_CERT_V01:
+ case RSA_SHA2_256_CERT_V01:
+ case RSA_SHA2_512_CERT_V01:
+ case SSH_DSS_CERT_V01:
+ case ECDSA_SHA2_NISTP256_CERT_V01:
+ case ECDSA_SHA2_NISTP384_CERT_V01:
+ case ECDSA_SHA2_NISTP521_CERT_V01:
+ case SSH_ED25519_CERT_V01:
+ case SSH_ED448_CERT_V01:
+ return true;
+ default:
+ return false;
+ }
+ }
+
+ /**
+ * Extracts the base key type from a certificate key type.
+ *
+ * For example, {@code ssh-rsa-cert-v01@openssh.com} returns {@code ssh-rsa}.
+ *
+ *
+ * @param certificateKeyType the certificate key type
+ * @return the base key type, or the original string if it's not a certificate type, or
+ * {@code null} if the input is {@code null}, empty or blank
+ */
+ static String getBaseKeyType(String certificateKeyType) {
+ if (certificateKeyType == null || certificateKeyType.isEmpty()) {
+ return null;
+ }
+ if (certificateKeyType.endsWith(CERT_SUFFIX)) {
+ return certificateKeyType.substring(0, certificateKeyType.length() - CERT_SUFFIX.length());
+ }
+ return certificateKeyType;
+ }
+
+ /**
+ * Returns the certificate key type for a given base signature algorithm.
+ *
+ * For example, {@code ssh-rsa} returns {@code ssh-rsa-cert-v01@openssh.com}.
+ *
+ *
+ * @param baseAlgorithm the base signature algorithm (e.g., "ssh-rsa", "ssh-ed25519")
+ * @return the corresponding certificate key type, or {@code null} if the algorithm is not
+ * recognized or is {@code null}
+ */
+ static String getCertificateKeyType(String baseAlgorithm) {
+ if (baseAlgorithm == null) {
+ return null;
+ }
+ switch (baseAlgorithm) {
+ case "ssh-rsa":
+ return SSH_RSA_CERT_V01;
+ case "rsa-sha2-256":
+ return RSA_SHA2_256_CERT_V01;
+ case "rsa-sha2-512":
+ return RSA_SHA2_512_CERT_V01;
+ case "ssh-dss":
+ return SSH_DSS_CERT_V01;
+ case "ecdsa-sha2-nistp256":
+ return ECDSA_SHA2_NISTP256_CERT_V01;
+ case "ecdsa-sha2-nistp384":
+ return ECDSA_SHA2_NISTP384_CERT_V01;
+ case "ecdsa-sha2-nistp521":
+ return ECDSA_SHA2_NISTP521_CERT_V01;
+ case "ssh-ed25519":
+ return SSH_ED25519_CERT_V01;
+ case "ssh-ed448":
+ return SSH_ED448_CERT_V01;
+ default:
+ return null;
+ }
+ }
+}
diff --git a/src/main/java/com/jcraft/jsch/OpenSshCertificateParser.java b/src/main/java/com/jcraft/jsch/OpenSshCertificateParser.java
index eb4b5fab4..5f1464f0f 100644
--- a/src/main/java/com/jcraft/jsch/OpenSshCertificateParser.java
+++ b/src/main/java/com/jcraft/jsch/OpenSshCertificateParser.java
@@ -3,6 +3,7 @@
import com.jcraft.jsch.JSch.InstanceLogger;
import java.nio.charset.StandardCharsets;
+import java.util.Arrays;
import java.util.Collection;
/**
@@ -25,6 +26,7 @@
* Protocol
*/
class OpenSshCertificateParser {
+
/**
* Parses the certificate data and returns a complete {@link OpenSshCertificate} object.
*
@@ -84,9 +86,7 @@ static OpenSshCertificate parse(InstanceLogger instLogger, byte[] certificateDat
int messageEndIndex = buffer.s;
- //
- byte[] message = new byte[messageEndIndex - 0];
- System.arraycopy(buffer.buffer, 0, message, 0, messageEndIndex - 0);
+ byte[] message = Arrays.copyOfRange(buffer.buffer, 0, messageEndIndex);
openSshCertificateBuilder.message(message);
@@ -123,43 +123,84 @@ static OpenSshCertificate parse(InstanceLogger instLogger, byte[] certificateDat
static KeyPair parsePublicKey(InstanceLogger instLogger, String keyType, Buffer buffer)
throws JSchException {
switch (keyType) {
-
- case OpenSshCertificateAwareIdentityFile.SSH_RSA_CERT_V01_AT_OPENSSH_DOT_COM:
+ case OpenSshCertificateKeyTypes.SSH_RSA_CERT_V01:
+ case OpenSshCertificateKeyTypes.RSA_SHA2_256_CERT_V01:
+ case OpenSshCertificateKeyTypes.RSA_SHA2_512_CERT_V01:
byte[] pub_array = buffer.getMPInt(); // e
byte[] n_array = buffer.getMPInt(); // n
return new KeyPairRSA(instLogger, n_array, pub_array, null);
-
- case OpenSshCertificateAwareIdentityFile.SSH_DSS_CERT_V01_AT_OPENSSH_DOT_COM:
+ case OpenSshCertificateKeyTypes.SSH_DSS_CERT_V01:
byte[] p_array = buffer.getMPInt();
byte[] q_array = buffer.getMPInt();
byte[] g_array = buffer.getMPInt();
byte[] y_array = buffer.getMPInt();
return new KeyPairDSA(instLogger, p_array, q_array, g_array, y_array, null);
-
- case OpenSshCertificateAwareIdentityFile.ECDSA_SHA2_NISTP256_CERT_V01_AT_OPENSSH_DOT_COM:
- case OpenSshCertificateAwareIdentityFile.ECDSA_SHA2_NISTP384_CERT_V01_AT_OPENSSH_DOT_COM:
- case OpenSshCertificateAwareIdentityFile.ECDSA_SHA2_NISTP521_CERT_V01_AT_OPENSSH_DOT_COM:
-
+ case OpenSshCertificateKeyTypes.ECDSA_SHA2_NISTP256_CERT_V01:
+ case OpenSshCertificateKeyTypes.ECDSA_SHA2_NISTP384_CERT_V01:
+ case OpenSshCertificateKeyTypes.ECDSA_SHA2_NISTP521_CERT_V01:
byte[] name = buffer.getString();
int len = buffer.getInt();
- int x04 = buffer.getByte();
+ int expectedLen = getExpectedEcdsaPointLength(keyType);
+ if (len != expectedLen) {
+ throw new JSchException("Invalid ECDSA public key length for " + keyType + ": expected "
+ + expectedLen + ", got " + len);
+ }
+ int pointFormat = buffer.getByte();
+ if (pointFormat != OpenSshCertificateUtil.EC_POINT_FORMAT_UNCOMPRESSED) {
+ throw new JSchException(
+ "Invalid ECDSA public key format: expected uncompressed point (0x04), got 0x"
+ + Integer.toHexString(pointFormat & 0xff));
+ }
byte[] r_array = new byte[(len - 1) / 2];
byte[] s_array = new byte[(len - 1) / 2];
buffer.getByte(r_array);
buffer.getByte(s_array);
return new KeyPairECDSA(instLogger, name, r_array, s_array, null);
-
- case OpenSshCertificateAwareIdentityFile.SSH_ED25519_CERT_V01_AT_OPENSSH_DOT_COM:
- byte[] ed25519_pub_array = new byte[buffer.getInt()];
+ case OpenSshCertificateKeyTypes.SSH_ED25519_CERT_V01:
+ int ed25519Len = buffer.getInt();
+ if (ed25519Len != OpenSshCertificateUtil.ED25519_PUBLIC_KEY_LENGTH) {
+ throw new JSchException("Invalid Ed25519 public key length: expected "
+ + OpenSshCertificateUtil.ED25519_PUBLIC_KEY_LENGTH + ", got " + ed25519Len);
+ }
+ byte[] ed25519_pub_array = new byte[ed25519Len];
buffer.getByte(ed25519_pub_array);
return new KeyPairEd25519(instLogger, ed25519_pub_array, null);
-
- case OpenSshCertificateAwareIdentityFile.SSH_ED448_CERT_V01_AT_OPENSSH_DOT_COM:
- byte[] ed448_pub_array = new byte[buffer.getInt()];
+ case OpenSshCertificateKeyTypes.SSH_ED448_CERT_V01:
+ int ed448Len = buffer.getInt();
+ if (ed448Len != OpenSshCertificateUtil.ED448_PUBLIC_KEY_LENGTH) {
+ throw new JSchException("Invalid Ed448 public key length: expected "
+ + OpenSshCertificateUtil.ED448_PUBLIC_KEY_LENGTH + ", got " + ed448Len);
+ }
+ byte[] ed448_pub_array = new byte[ed448Len];
buffer.getByte(ed448_pub_array);
return new KeyPairEd448(instLogger, ed448_pub_array, null);
default:
throw new JSchException("Unsupported Algorithm for Certificate public key: " + keyType);
}
}
+
+ /**
+ * Returns the expected uncompressed EC point length for a given ECDSA certificate key type.
+ *
+ *
+ * The uncompressed point format consists of a 0x04 prefix byte followed by the X and Y
+ * coordinates. The total length is therefore: coordinate_size * 2 + 1.
+ *
+ *
+ * @param keyType the certificate key type
+ * @return the expected point length in bytes
+ * @throws JSchException if the key type is not a recognized ECDSA certificate type
+ */
+ private static int getExpectedEcdsaPointLength(String keyType) throws JSchException {
+ switch (keyType) {
+ case OpenSshCertificateKeyTypes.ECDSA_SHA2_NISTP256_CERT_V01:
+ return OpenSshCertificateUtil.ECDSA_P256_POINT_LENGTH;
+ case OpenSshCertificateKeyTypes.ECDSA_SHA2_NISTP384_CERT_V01:
+ return OpenSshCertificateUtil.ECDSA_P384_POINT_LENGTH;
+ case OpenSshCertificateKeyTypes.ECDSA_SHA2_NISTP521_CERT_V01:
+ return OpenSshCertificateUtil.ECDSA_P521_POINT_LENGTH;
+ default:
+ throw new JSchException("Unknown ECDSA certificate key type: " + keyType);
+ }
+ }
}
diff --git a/src/main/java/com/jcraft/jsch/OpenSshCertificateUtil.java b/src/main/java/com/jcraft/jsch/OpenSshCertificateUtil.java
index 8adc06231..3bcc11214 100644
--- a/src/main/java/com/jcraft/jsch/OpenSshCertificateUtil.java
+++ b/src/main/java/com/jcraft/jsch/OpenSshCertificateUtil.java
@@ -24,7 +24,7 @@ class OpenSshCertificateUtil {
* certificates presented by hosts during authentication.
*
*/
- static Predicate isKnownHostCaPublicKeyEntry =
+ static final Predicate isKnownHostCaPublicKeyEntry =
hostKey -> Objects.nonNull(hostKey) && "@cert-authority".equals(hostKey.getMarker());
/**
@@ -35,38 +35,74 @@ class OpenSshCertificateUtil {
* {@code null} entries as revoked.
*
*/
- static Predicate isMarkedRevoked =
+ static final Predicate isMarkedRevoked =
hostKey -> hostKey == null || "@revoked".equals(hostKey.getMarker());
/**
- * Converts a byte array to a UTF-8 string, replaces tab characters with spaces, and trims
- * whitespace.
+ * EC point format indicator for uncompressed points (SEC 1, section 2.3.3).
+ *
+ * In the uncompressed point format, the first byte is 0x04, followed by the X and Y coordinates.
+ * This is the standard format used in SSH for ECDSA public keys.
+ *
*
- * @param s the byte array to convert and process, may be null
- * @return the processed string with tabs converted to spaces and whitespace trimmed, or an empty
- * string if the input is null
- * @see #tabToSpaceAndTrim(String)
+ * @see SEC 1: Elliptic Curve Cryptography
*/
- static String tabToSpaceAndTrim(byte[] s) {
- String str = new String(s, StandardCharsets.UTF_8);
- return tabToSpaceAndTrim(str);
- }
+ static final int EC_POINT_FORMAT_UNCOMPRESSED = 0x04;
/**
- * Replaces all tab characters in the input string with space characters and trims leading and
- * trailing whitespace.
+ * Expected public key length for Ed25519 keys (32 bytes).
+ *
+ * Ed25519 uses a 256-bit (32-byte) public key as defined in RFC 8032.
+ *
*
- * @param s the string to process, may be null
- * @return the processed string with tabs converted to spaces and whitespace trimmed, or an empty
- * string if the input is null
- * @see #trimToEmptyIfNull(String)
+ * @see RFC 8032: Edwards-Curve Digital Signature
+ * Algorithm (EdDSA)
*/
- static String tabToSpaceAndTrim(String s) {
- if (s != null) {
- s = s.replace('\t', ' ');
- }
- return trimToEmptyIfNull(s);
- }
+ static final int ED25519_PUBLIC_KEY_LENGTH = 32;
+
+ /**
+ * Expected public key length for Ed448 keys (57 bytes).
+ *
+ * Ed448 uses a 456-bit public key, which requires 57 bytes when encoded, as defined in RFC 8032.
+ *
+ *
+ * @see RFC 8032: Edwards-Curve Digital Signature
+ * Algorithm (EdDSA)
+ */
+ static final int ED448_PUBLIC_KEY_LENGTH = 57;
+
+ /**
+ * Expected uncompressed EC point length for NIST P-256 keys (65 bytes).
+ *
+ * The uncompressed point format is: 0x04 || X coordinate (32 bytes) || Y coordinate (32 bytes).
+ *
+ *
+ * @see RFC 5656: Elliptic Curve Algorithm
+ * Integration in the Secure Shell Transport Layer
+ */
+ static final int ECDSA_P256_POINT_LENGTH = 65;
+
+ /**
+ * Expected uncompressed EC point length for NIST P-384 keys (97 bytes).
+ *
+ * The uncompressed point format is: 0x04 || X coordinate (48 bytes) || Y coordinate (48 bytes).
+ *
+ *
+ * @see RFC 5656: Elliptic Curve Algorithm
+ * Integration in the Secure Shell Transport Layer
+ */
+ static final int ECDSA_P384_POINT_LENGTH = 97;
+
+ /**
+ * Expected uncompressed EC point length for NIST P-521 keys (133 bytes).
+ *
+ * The uncompressed point format is: 0x04 || X coordinate (66 bytes) || Y coordinate (66 bytes).
+ *
+ *
+ * @see RFC 5656: Elliptic Curve Algorithm
+ * Integration in the Secure Shell Transport Layer
+ */
+ static final int ECDSA_P521_POINT_LENGTH = 133;
/**
* Trims leading and trailing whitespace from the input string. Returns an empty string if the
@@ -118,36 +154,34 @@ static boolean isEmpty(Map, ?> c) {
* type is the first field (index 0) in the space-delimited string.
*
* @param certificateFileContent The content of the certificate file as a byte array.
- * @return The key type string, or {@code null} if the content is invalid or the field does not
- * exist.
- * @throws IllegalArgumentException if the certificate content is null or empty after trimming.
+ * @return The key type as a byte array, or {@code null} if the content is null, empty, or the
+ * field does not exist.
*/
- static byte[] extractKeyType(byte[] certificateFileContent) throws IllegalArgumentException {
+ static byte[] extractKeyType(byte[] certificateFileContent) {
return extractSpaceDelimitedString(certificateFileContent, 0);
}
/**
- * Extracts the comment from a certificate file content string. This method assumes the comment is
- * the third field (index 2) in the space-delimited string.
+ * Extracts the comment from a certificate file content byte array. This method assumes the
+ * comment is the third field (index 2) in the space-delimited string.
*
- * @param certificateFileContent The content of the certificate file as a single string.
- * @return The comment string, or {@code null} if the content is invalid or the field does not
- * exist.
- * @throws IllegalArgumentException if the certificate content is null or empty after trimming.
+ * @param certificateFileContent The content of the certificate file as a byte array.
+ * @return The comment as a byte array, or {@code null} if the content is null, empty, or the
+ * field does not exist.
*/
- static byte[] extractComment(byte[] certificateFileContent) throws IllegalArgumentException {
+ static byte[] extractComment(byte[] certificateFileContent) {
return extractSpaceDelimitedString(certificateFileContent, 2);
}
/**
- * Extracts the key data from a certificate file content string. This method assumes the key data
- * is the second field (index 1) in the space-delimited string.
+ * Extracts the key data from a certificate file content byte array. This method assumes the key
+ * data is the second field (index 1) in the space-delimited string.
*
- * @param certificateFileContent The content of the certificate file as a single string.
- * @return The key data string, or {@code null} if the content is invalid or the field does not
- * exist.
+ * @param certificateFileContent The content of the certificate file as a byte array.
+ * @return The key data as a byte array, or {@code null} if the content is null, empty, or the
+ * field does not exist.
*/
- static byte[] extractKeyData(byte[] certificateFileContent) throws IllegalArgumentException {
+ static byte[] extractKeyData(byte[] certificateFileContent) {
return extractSpaceDelimitedString(certificateFileContent, 1);
}
@@ -176,6 +210,9 @@ static byte[] extractSpaceDelimitedString(byte[] certificate, int index) {
if (certificate == null || certificate.length == 0) {
return null;
}
+ if (index < 0) {
+ return null;
+ }
int fieldCount = 0;
int fieldStart = -1;
@@ -233,9 +270,18 @@ static byte[] extractSpaceDelimitedString(byte[] certificate, int index) {
* otherwise
*/
static boolean isValidNow(OpenSshCertificate cert) {
+ return isValidNow(cert, Instant.now().getEpochSecond());
+ }
- long now = Instant.now().getEpochSecond();
-
+ /**
+ * Determines whether the given {@link OpenSshCertificate} is valid at the given time.
+ *
+ * @param cert to check
+ * @param now the current time in seconds since epoch
+ * @return {@code true} if the certificate is valid according to its timestamps, {@code false}
+ * otherwise
+ */
+ static boolean isValidNow(OpenSshCertificate cert, long now) {
return Long.compareUnsigned(cert.getValidAfter(), now) <= 0
&& Long.compareUnsigned(now, cert.getValidBefore()) < 0;
}
@@ -260,66 +306,20 @@ static String toDateString(long timestamp) {
/**
* Extracts the raw key type from a given key type string.
*
- * This method searches for the first occurrence of the substring "-cert" and returns all
- * characters that appear before it. If the substring is not found, the original string is
- * returned unchanged.
+ * For certificate key types (ending with {@code -cert-v01@openssh.com}), this method returns the
+ * base algorithm name (e.g., {@code ssh-rsa-cert-v01@openssh.com} returns {@code ssh-rsa}). For
+ * non-certificate key types, the original string is returned unchanged.
+ *
+ *
+ * This method delegates to {@link OpenSshCertificateKeyTypes#getBaseKeyType(String)}.
+ *
*
* @param keyType The full key type string, may be null.
- * @return The raw key type (e.g., "ssh-rsa"), more in general the substring before the first
- * occurrence of "-cert", or the original string if "-cert" is not found, or null if the
- * input is null.
+ * @return The raw key type (e.g., "ssh-rsa"), or the original string if not a certificate type,
+ * or null if the input is null or empty.
*/
static String getRawKeyType(String keyType) {
- if (isEmpty(keyType)) {
- return null;
- }
- int index = keyType.indexOf("-cert");
- if (index == -1) {
- return keyType; // "-cert" not found, return original string
- }
-
- return keyType.substring(0, index);
- }
-
- /**
- * Checks if a given byte array represents an OpenSSH host certificate.
- *
- * This method parses the provided byte array to determine if it conforms to the structure of an
- * OpenSSH certificate and, if so, verifies that its type is a host certificate. It performs
- * checks for null or empty input, validates the key type, and then extracts the certificate type
- * from the buffer.
- *
- * @param instLogger An instance of {@link JSch.InstanceLogger} for logging purposes.
- * @param bytes The byte array containing the certificate data to be checked.
- * @return {@code true} if the byte array represents a valid OpenSSH host certificate;
- * {@code false} otherwise.
- * @throws JSchException if there is an issue parsing the certificate data, such as malformed
- * data.
- */
- static boolean isOpenSshHostCertificate(JSch.InstanceLogger instLogger, byte[] bytes)
- throws JSchException {
- if (bytes == null || bytes.length == 0) {
- return false;
- }
-
- OpenSshCertificateBuffer buffer = new OpenSshCertificateBuffer(bytes);
-
- String keyType = Util.byte2str(buffer.getString(), StandardCharsets.UTF_8);
- if (isEmpty(keyType)
- || !OpenSshCertificateAwareIdentityFile.isOpenSshCertificateKeyType(keyType)) {
- return false;
- }
-
- // discard nonce
- buffer.getString();
- // public key
- OpenSshCertificateParser.parsePublicKey(instLogger, keyType, buffer);
- // serial
- buffer.getLong();
- // type
- int certificateType = buffer.getInt();
-
- return certificateType == OpenSshCertificate.SSH2_CERT_TYPE_HOST;
+ return OpenSshCertificateKeyTypes.getBaseKeyType(keyType);
}
/**
@@ -340,8 +340,7 @@ static boolean isOpenSshHostCertificate(JSch.InstanceLogger instLogger, byte[] b
*
* - {@code ssh-ed25519} → {@code ssh-ed25519-cert-v01@openssh.com}
* - {@code ssh-ed448} → {@code ssh-ed448-cert-v01@openssh.com}
- * - {@code ssh-rsa} → {@code ssh-rsa-cert-v01@openssh.com},
- * {@code rsa-sha2-256-cert-v01@openssh.com}, {@code rsa-sha2-512-cert-v01@openssh.com}
+ * - {@code ssh-rsa} → {@code ssh-rsa-cert-v01@openssh.com}
* - {@code rsa-sha2-256} → {@code rsa-sha2-256-cert-v01@openssh.com}
* - {@code rsa-sha2-512} → {@code rsa-sha2-512-cert-v01@openssh.com}
* - {@code ssh-dss} → {@code ssh-dss-cert-v01@openssh.com}
@@ -349,11 +348,6 @@ static boolean isOpenSshHostCertificate(JSch.InstanceLogger instLogger, byte[] b
* - {@code ecdsa-sha2-nistp384} → {@code ecdsa-sha2-nistp384-cert-v01@openssh.com}
* - {@code ecdsa-sha2-nistp521} → {@code ecdsa-sha2-nistp521-cert-v01@openssh.com}
*
- *
- * Note: RSA has special handling because {@code ssh-rsa} being unavailable implies that
- * RSA signature verification is completely unavailable, so all RSA-based certificate types are
- * removed.
- *
*
* @param serverHostKey comma-separated list of server host key algorithms to filter. This
* typically contains a mix of plain key algorithms (e.g., {@code ssh-ed25519}) and
@@ -381,31 +375,14 @@ static String filterUnavailableCertTypes(String serverHostKey, String[] unavaila
List certsToRemove = new ArrayList();
for (String unavailableSig : unavailableSignatures) {
- // For each unavailable signature, add corresponding certificate types
- if ("ssh-ed25519".equals(unavailableSig)) {
- certsToRemove.add("ssh-ed25519-cert-v01@openssh.com");
- } else if ("ssh-ed448".equals(unavailableSig)) {
- certsToRemove.add("ssh-ed448-cert-v01@openssh.com");
- } else if ("ssh-rsa".equals(unavailableSig)) {
- certsToRemove.add("ssh-rsa-cert-v01@openssh.com");
- certsToRemove.add("rsa-sha2-256-cert-v01@openssh.com");
- certsToRemove.add("rsa-sha2-512-cert-v01@openssh.com");
- } else if ("rsa-sha2-256".equals(unavailableSig)) {
- certsToRemove.add("rsa-sha2-256-cert-v01@openssh.com");
- } else if ("rsa-sha2-512".equals(unavailableSig)) {
- certsToRemove.add("rsa-sha2-512-cert-v01@openssh.com");
- } else if ("ssh-dss".equals(unavailableSig)) {
- certsToRemove.add("ssh-dss-cert-v01@openssh.com");
- } else if ("ecdsa-sha2-nistp256".equals(unavailableSig)) {
- certsToRemove.add("ecdsa-sha2-nistp256-cert-v01@openssh.com");
- } else if ("ecdsa-sha2-nistp384".equals(unavailableSig)) {
- certsToRemove.add("ecdsa-sha2-nistp384-cert-v01@openssh.com");
- } else if ("ecdsa-sha2-nistp521".equals(unavailableSig)) {
- certsToRemove.add("ecdsa-sha2-nistp521-cert-v01@openssh.com");
+ // Map base algorithm to corresponding certificate type using centralized mapping
+ String certType = OpenSshCertificateKeyTypes.getCertificateKeyType(unavailableSig);
+ if (certType != null) {
+ certsToRemove.add(certType);
}
}
- if (certsToRemove.size() > 0) {
+ if (!certsToRemove.isEmpty()) {
String[] certsArray = new String[certsToRemove.size()];
certsToRemove.toArray(certsArray);
serverHostKey = Util.diffString(serverHostKey, certsArray);
@@ -461,27 +438,22 @@ static String filterUnavailableCertTypes(String serverHostKey, String[] unavaila
* {@code null}
* @param host the hostname or host pattern being connected to (e.g., "host.example.com" or
* "[host.example.com]:2222"), must not be {@code null}
- * @param base64CaPublicKey the Base64-encoded CA public key from the certificate that needs
- * validation, must not be {@code null}
+ * @param caPublicKey the raw CA public key bytes from the certificate that needs validation, must
+ * not be {@code null}
* @return {@code true} if a trusted, non-revoked CA matching the host pattern signed the
* certificate; {@code false} if no matching CA exists, all matching CAs are revoked, or
* the CA key doesn't match
*/
static boolean isCertificateSignedByTrustedCA(HostKeyRepository repository, String host,
- String base64CaPublicKey) throws JSchException {
- final Set revokedKeys = getRevokedKeys(repository);
- byte[] publicKeyBytes = Util.fromBase64(base64CaPublicKey.getBytes(StandardCharsets.UTF_8));
-
+ byte[] caPublicKey) throws JSchException {
return getTrustedCAs(repository).stream().filter(Objects::nonNull)
.filter(hostkey -> !hasBeenRevoked(repository, hostkey)).anyMatch(trustedCA -> {
- try {
- byte[] trustedCAKeyBytes =
- Util.fromBase64(trustedCA.getKey().getBytes(StandardCharsets.UTF_8));
- return trustedCA.isWildcardMatched(host) && trustedCA.getKey() != null
- && Arrays.equals(trustedCAKeyBytes, publicKeyBytes);
- } catch (Exception e) {
+ byte[] trustedCAKeyBytes = trustedCA.key;
+ if (trustedCAKeyBytes == null) {
return false;
}
+ return trustedCA.isWildcardMatched(host)
+ && Util.arraysequals(trustedCAKeyBytes, caPublicKey);
});
}
@@ -554,7 +526,110 @@ static boolean hasBeenRevoked(HostKeyRepository knownHosts, HostKey key) {
if (key == null) {
return true;
}
+ byte[] keyBytes = key.key;
+ if (keyBytes == null) {
+ // Fail-closed: treat keys with null key value as revoked
+ return true;
+ }
+ // Compare raw byte arrays directly using timing-safe comparison
return getRevokedKeys(knownHosts).stream().filter(Objects::nonNull)
- .anyMatch(revokedKey -> revokedKey.getKey().equals(key.getKey()));
+ .map(revokedKey -> revokedKey.key).filter(Objects::nonNull)
+ .anyMatch(revokedKeyBytes -> Util.arraysequals(revokedKeyBytes, keyBytes));
+ }
+
+ /**
+ * Parses a raw public key byte array into its constituent mathematical components.
+ *
+ * Different public key algorithms (RSA, DSS, ECDSA, EdDSA) have different structures. This method
+ * decodes the algorithm-specific format and returns the components needed for cryptographic
+ * operations.
+ *
+ *
+ * @param publicKeyBlob the raw byte array of the public key blob in SSH wire format.
+ * @return A 2D byte array where each inner array is a component of the public key.
+ * @throws JSchException if the public key algorithm is unknown or the key format is corrupt.
+ */
+ static byte[][] parsePublicKeyComponents(byte[] publicKeyBlob) throws JSchException {
+ Buffer buffer = new Buffer(publicKeyBlob);
+ String algorithm = Util.byte2str(buffer.getString());
+ return parsePublicKeyComponentsFromBuffer(algorithm, buffer);
+ }
+
+ /**
+ * Parses public key components from a buffer based on the algorithm.
+ *
+ * This method reads key components from the current buffer position. The buffer should be
+ * positioned after the algorithm string.
+ *
+ *
+ * @param algorithm the public key algorithm name.
+ * @param buffer the buffer containing the key components.
+ * @return A 2D byte array where each inner array is a component of the public key.
+ * @throws JSchException if the algorithm is unknown or the key format is invalid.
+ */
+ static byte[][] parsePublicKeyComponentsFromBuffer(String algorithm, Buffer buffer)
+ throws JSchException {
+
+ if (algorithm.startsWith("ssh-rsa") || algorithm.startsWith("rsa-")) {
+ byte[] e = buffer.getMPInt();
+ byte[] n = buffer.getMPInt();
+ return new byte[][] {e, n};
+ }
+
+ if (algorithm.startsWith("ssh-dss")) {
+ byte[] p = buffer.getMPInt();
+ byte[] q = buffer.getMPInt();
+ byte[] g = buffer.getMPInt();
+ byte[] y = buffer.getMPInt();
+ // Order matches SignatureDSA.setPubKey(y, p, q, g)
+ return new byte[][] {y, p, q, g};
+ }
+
+ if (algorithm.startsWith("ecdsa-sha2-")) {
+ // https://www.rfc-editor.org/rfc/rfc5656#section-3.1
+ buffer.getString(); // skip curve identifier
+ int len = buffer.getInt();
+ if (len < ECDSA_P256_POINT_LENGTH || len > ECDSA_P521_POINT_LENGTH) {
+ throw new JSchException("Invalid ECDSA public key length: " + len + " (expected between "
+ + ECDSA_P256_POINT_LENGTH + " and " + ECDSA_P521_POINT_LENGTH + ")");
+ }
+ int pointFormat = buffer.getByte();
+ if (pointFormat != EC_POINT_FORMAT_UNCOMPRESSED) {
+ throw new JSchException(
+ "Invalid ECDSA public key format: expected uncompressed point (0x04), got 0x"
+ + Integer.toHexString(pointFormat & 0xff));
+ }
+ byte[] r = new byte[(len - 1) / 2];
+ byte[] s = new byte[(len - 1) / 2];
+ buffer.getByte(r);
+ buffer.getByte(s);
+ // Order matches SignatureECDSA.setPubKey(r, s)
+ return new byte[][] {r, s};
+ }
+
+ if (algorithm.startsWith("ssh-ed25519")) {
+ int keyLength = buffer.getInt();
+ if (keyLength != ED25519_PUBLIC_KEY_LENGTH) {
+ throw new JSchException("Invalid Ed25519 public key length: expected "
+ + ED25519_PUBLIC_KEY_LENGTH + ", got " + keyLength);
+ }
+ byte[] pubKey = new byte[keyLength];
+ buffer.getByte(pubKey);
+ return new byte[][] {pubKey};
+ }
+
+ if (algorithm.startsWith("ssh-ed448")) {
+ int keyLength = buffer.getInt();
+ if (keyLength != ED448_PUBLIC_KEY_LENGTH) {
+ throw new JSchException("Invalid Ed448 public key length: expected "
+ + ED448_PUBLIC_KEY_LENGTH + ", got " + keyLength);
+ }
+ byte[] pubKey = new byte[keyLength];
+ buffer.getByte(pubKey);
+ return new byte[][] {pubKey};
+ }
+
+ throw new JSchUnknownPublicKeyAlgorithmException(
+ "Unknown algorithm '" + algorithm.trim() + "'");
}
}
diff --git a/src/main/java/com/jcraft/jsch/Session.java b/src/main/java/com/jcraft/jsch/Session.java
index 2519a2ac5..7457b24be 100644
--- a/src/main/java/com/jcraft/jsch/Session.java
+++ b/src/main/java/com/jcraft/jsch/Session.java
@@ -941,17 +941,27 @@ private void send_extinfo() throws Exception {
}
}
- private void checkHost(String chost, int port, KeyExchange kex) throws Exception {
- if (kex.isOpenSshServerHostKeyType) {
- OpenSshCertificate certificate = kex.getHostKeyCertificate();
+ private void checkHost(String chost, int port, KeyExchange kex) throws JSchException {
+ OpenSshCertificate certificate = kex.getHostKeyCertificate();
+ if (certificate != null) {
try {
OpenSshCertificateHostKeyVerifier.checkHostCertificate(this, certificate);
return;
} catch (JSchException e) {
- if (getConfig("HostCertificateToKeyFallback").equals("no")) {
+ if (getConfig("host_certificate_to_key_fallback").equals("no")) {
throw e;
}
+ // Fallback to public key verification - log warning as this bypasses CA validation
+ if (getLogger().isEnabled(Logger.WARN)) {
+ getLogger().log(Logger.WARN,
+ "Host certificate validation failed, falling back to public key verification. "
+ + "This bypasses CA trust validation. Reason: " + e.getMessage());
+ }
byte[] K_S = certificate.getCertificatePublicKey();
+ if (K_S == null) {
+ throw new JSchException(
+ "Invalid certificate '" + certificate.getId() + "': missing public key");
+ }
String key_type = kex.getKeyType();
String key_footprint = kex.getFingerPrint();
String keyAlgorithmName = kex.getKeyAlgorithName();
@@ -965,7 +975,6 @@ private void checkHostKey(String chost, int port, KeyExchange kex) throws JSchEx
if (hostKeyAlias != null) {
chost = hostKeyAlias;
}
- // System.err.println("shkc: "+shkc);
byte[] K_S = kex.getHostKey();
String key_type = kex.getKeyType();
String key_fprint = kex.getFingerPrint();
@@ -982,6 +991,7 @@ private void doCheckHostKey(String chost, String key_type, String key_fprint,
}
String shkc = getConfig("StrictHostKeyChecking");
+ // System.err.println("shkc: "+shkc);
HostKeyRepository hkr = getHostKeyRepository();
@@ -2299,7 +2309,6 @@ public void setThreadFactory(ThreadFactory threadFactory) {
this.threadFactory = Objects.requireNonNull(threadFactory);
}
-
/**
* Returns the thread factory used by this instance.
*
@@ -2484,7 +2493,6 @@ public void setPortForwardingR(int rport, String host, int lport, SocketFactory
}
// TODO: This method should return the integer value as the assigned port.
-
/**
* Registers the remote port forwarding. If bind_address is an empty string or
* "*", the port should be available from all interfaces. If
@@ -3305,8 +3313,11 @@ private String[] checkSignatures(String sigs) {
String[] _sigs = Util.split(sigs, ",");
for (int i = 0; i < _sigs.length; i++) {
try {
+ // Map certificate algorithm names to their base signature algorithm.
+ // Certificate algorithms use the same Signature implementations as their base algorithms.
+ String sigToCheck = OpenSshCertificateKeyTypes.getBaseKeyType(_sigs[i]);
Class extends Signature> c =
- Class.forName(JSch.getConfig(_sigs[i])).asSubclass(Signature.class);
+ Class.forName(JSch.getConfig(sigToCheck)).asSubclass(Signature.class);
final Signature sig = c.getDeclaredConstructor().newInstance();
sig.init();
} catch (Exception | LinkageError e) {
@@ -3325,6 +3336,57 @@ private String[] checkSignatures(String sigs) {
return foo;
}
+ /**
+ * Checks if a CA signature algorithm is allowed and available at runtime.
+ *
+ * This method validates that the given algorithm is:
+ *
+ * - Listed in the configured {@code ca_signature_algorithms}
+ * - Not in the list of unavailable signature algorithms (as determined by
+ * {@code CheckSignatures})
+ *
+ *
+ *
+ * @param algorithm the CA signature algorithm to check
+ * @throws JSchException if the algorithm is not allowed or not available at runtime
+ */
+ void checkCASignatureAlgorithm(String algorithm) throws JSchException {
+ String caSignatureAlgorithms = getConfig("ca_signature_algorithms");
+
+ if (caSignatureAlgorithms != null && !caSignatureAlgorithms.isEmpty()) {
+ // Check if algorithm is in the allowed list
+ String[] allowedAlgorithms = Util.split(caSignatureAlgorithms, ",");
+ boolean isAllowed = false;
+ for (String allowed : allowedAlgorithms) {
+ if (allowed.equals(algorithm)) {
+ isAllowed = true;
+ break;
+ }
+ }
+
+ if (!isAllowed) {
+ throw new JSchException("CA signature algorithm '" + algorithm
+ + "' is not in the allowed ca_signature_algorithms list: " + caSignatureAlgorithms);
+ }
+ }
+
+ // Check if the algorithm is in the cached unavailable signatures list.
+ // Copy volatile field to local variable to avoid race condition.
+ String[] unavailableSigs = not_available_shks;
+ if (unavailableSigs != null) {
+ for (String unavailable : unavailableSigs) {
+ if (unavailable.equals(algorithm)) {
+ throw new JSchException(
+ "CA signature algorithm '" + algorithm + "' is not available at runtime. "
+ + "This may be due to missing cryptographic provider support "
+ + "(e.g., Ed25519 on Java 8 without Bouncy Castle).");
+ }
+ }
+ }
+ // If unavailableSigs is null, either all probed algorithms are available,
+ // or the user chose not to probe via CheckSignatures - respect that choice.
+ }
+
/**
* Sets the identityRepository, which will be referred in the public key authentication. The
* default value is null.
diff --git a/src/main/java/com/jcraft/jsch/SignatureWrapper.java b/src/main/java/com/jcraft/jsch/SignatureWrapper.java
index d01e2eedd..005b2836f 100644
--- a/src/main/java/com/jcraft/jsch/SignatureWrapper.java
+++ b/src/main/java/com/jcraft/jsch/SignatureWrapper.java
@@ -1,14 +1,12 @@
package com.jcraft.jsch;
-import java.nio.charset.StandardCharsets;
-
/**
* A factory and wrapper class for creating and managing digital signature instances.
*
* This class abstracts the creation of specific signature algorithm implementations (like RSA, DSA,
* ECDSA, EdDSA) by dynamically loading them based on an algorithm name. It also provides a generic
* interface for setting the public key and performing signature operations (init, update, verify,
- * sign) by delegating calls to the - * underlying signature instance.
+ * sign) by delegating calls to the underlying signature instance.
*
*/
class SignatureWrapper implements Signature {
@@ -36,7 +34,7 @@ class SignatureWrapper implements Signature {
// Session.getConfig(algorithm)
this.signature = Class.forName(session.getConfig(algorithm)).asSubclass(Signature.class)
.getDeclaredConstructor().newInstance();
- } catch (Exception e) {
+ } catch (Exception | LinkageError e) {
throw new JSchException("Failed to instantiate signature for algorithm '" + algorithm + "'",
e);
}
@@ -65,16 +63,16 @@ class SignatureWrapper implements Signature {
* Generates a validator for the public key parameters.
*
* @param algorithm the algorithm name, used for error messages.
- * @param expectedParametersNo the exact number of byte arrays expected for the public key.
+ * @param expectedNumParams the exact number of byte arrays expected for the public key.
* @return a {@link PubKeyParameterValidator} instance.
* @throws JSchException if the number of provided parameters does not match the expected count.
*/
- private static PubKeyParameterValidator generateValidator(String algorithm,
- int expectedParametersNo) throws JSchException {
+ private static PubKeyParameterValidator generateValidator(String algorithm, int expectedNumParams)
+ throws JSchException {
return (byte[][] params) -> {
- if (params.length != expectedParametersNo) {
+ if (params.length != expectedNumParams) {
throw new JSchException("wrong number of arguments:" + algorithm + " signatures expects "
- + expectedParametersNo + " parameters, found " + params.length);
+ + expectedNumParams + " parameters, found " + params.length);
}
};
}
diff --git a/src/main/java/com/jcraft/jsch/UserAuthPublicKey.java b/src/main/java/com/jcraft/jsch/UserAuthPublicKey.java
index f0b2b48d9..bff1835d8 100644
--- a/src/main/java/com/jcraft/jsch/UserAuthPublicKey.java
+++ b/src/main/java/com/jcraft/jsch/UserAuthPublicKey.java
@@ -187,11 +187,8 @@ private boolean _start(Session session, List identities, List
loop3: while (it.hasNext()) {
String ipkmethod = it.next();
it.remove();
- if (not_available_pks.contains(ipkmethod) && !(identity instanceof AgentIdentity)) {
- if (session.getLogger().isEnabled(Logger.DEBUG)) {
- session.getLogger().log(Logger.DEBUG,
- ipkmethod + " not available for identity " + identity.getName());
- }
+ // Map certificate type to base algorithm for availability check
+ if (isAlgorithmUnavailable(ipkmethod, not_available_pks, identity, session)) {
continue loop3;
}
@@ -275,11 +272,8 @@ private boolean _start(Session session, List identities, List
loop4: while (it.hasNext() && session.auth_failures < session.max_auth_tries) {
String pkmethodsuccess = it.next();
it.remove();
- if (not_available_pks.contains(pkmethodsuccess) && !(identity instanceof AgentIdentity)) {
- if (session.getLogger().isEnabled(Logger.DEBUG)) {
- session.getLogger().log(Logger.DEBUG,
- pkmethodsuccess + " not available for identity " + identity.getName());
- }
+ // Map certificate type to base algorithm for availability check
+ if (isAlgorithmUnavailable(pkmethodsuccess, not_available_pks, identity, session)) {
continue loop4;
}
@@ -382,6 +376,31 @@ private boolean _start(Session session, List identities, List
return false;
}
+ /**
+ * Checks if a public key algorithm is unavailable for a non-agent identity.
+ *
+ * For certificate key types, this checks the availability of the base algorithm.
+ *
+ *
+ * @param pkmethod the public key method/algorithm to check
+ * @param not_available_pks list of unavailable algorithms
+ * @param identity the identity being used
+ * @param session the current session (for logging)
+ * @return true if the algorithm is unavailable and should be skipped, false otherwise
+ */
+ private boolean isAlgorithmUnavailable(String pkmethod, List not_available_pks,
+ Identity identity, Session session) {
+ String baseAlgorithm = OpenSshCertificateKeyTypes.getBaseKeyType(pkmethod);
+ if (not_available_pks.contains(baseAlgorithm) && !(identity instanceof AgentIdentity)) {
+ if (session.getLogger().isEnabled(Logger.DEBUG)) {
+ session.getLogger().log(Logger.DEBUG,
+ pkmethod + " not available for identity " + identity.getName());
+ }
+ return true;
+ }
+ return false;
+ }
+
private void decryptKey(Session session, Identity identity) throws JSchException {
byte[] passphrase = null;
int count = 5;
diff --git a/src/test/java/com/jcraft/jsch/HostCertificateIT.java b/src/test/java/com/jcraft/jsch/HostCertificateIT.java
index ee400cd43..b2a88517f 100644
--- a/src/test/java/com/jcraft/jsch/HostCertificateIT.java
+++ b/src/test/java/com/jcraft/jsch/HostCertificateIT.java
@@ -1,17 +1,20 @@
package com.jcraft.jsch;
-import java.util.Arrays;
-import java.util.List;
import com.github.valfirst.slf4jtest.LoggingEvent;
import com.github.valfirst.slf4jtest.TestLogger;
import com.github.valfirst.slf4jtest.TestLoggerFactory;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.MethodSource;
+import org.slf4j.LoggerFactory;
import org.testcontainers.containers.GenericContainer;
import org.testcontainers.containers.wait.strategy.Wait;
import org.testcontainers.images.builder.ImageFromDockerfile;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
+
+import java.util.Arrays;
+import java.util.List;
+
import static com.jcraft.jsch.ResourceUtil.getResourceFile;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import static org.junit.jupiter.api.Assertions.assertThrows;
@@ -25,7 +28,6 @@
* to validate server host keys signed by a Certificate Authority (CA) against a {@code known_hosts}
* file.
*/
-
@Testcontainers
public class HostCertificateIT {
@@ -37,7 +39,14 @@ public class HostCertificateIT {
private static final TestLogger jschLogger = TestLoggerFactory.getTestLogger(JSch.class);
/** Test logger for capturing SSH server logs (via a placeholder class). */
private static final TestLogger sshdLogger =
- TestLoggerFactory.getTestLogger(UserCertAuthIT.class);
+ TestLoggerFactory.getTestLogger(HostCertificateIT.class);
+ private static org.slf4j.Logger LOGGER = LoggerFactory.getLogger(HostCertificateIT.class);
+
+ /*
+ * @BeforeAll static void beforeAll() { JSch.setLogger(LOGGER); }
+ *
+ * @AfterAll static void afterAll() { JSch.setLogger(null); }
+ */
/**
* Defines and configures the SSH server Docker container using Testcontainers. The container is
@@ -69,7 +78,6 @@ public class HostCertificateIT {
.withFileFromClasspath("Dockerfile", CERTIFICATES_BASE_FOLDER + "/Dockerfile"))
.withExposedPorts(22).waitingFor(Wait.forLogMessage(".*Server listening on :: port 22.*", 1));
-
/**
* Provides a stream of server host key algorithms to be used in parameterized tests. Each string
* corresponds to the {@code server_host_key} configuration option in JSch.
@@ -127,7 +135,6 @@ public void hostKeyTestHappyPath(String algorithm) throws Exception {
@ParameterizedTest(name = "hostkey algorithm: {0}")
public void hostKeyTestNotTrustedCA(String algorithm) throws Exception {
JSch ssh = new JSch();
-
ssh.addIdentity(
getResourceFile(this.getClass(), CERTIFICATES_BASE_FOLDER + "/user_keys/id_ecdsa_nistp521"),
getResourceFile(this.getClass(),
@@ -191,8 +198,5 @@ private void printInfo() {
.forEach(System.out::println);
sshdLogger.getAllLoggingEvents().stream().map(LoggingEvent::getFormattedMessage)
.forEach(System.out::println);
- System.out.println("");
- System.out.println("");
- System.out.println("");
}
}
diff --git a/src/test/java/com/jcraft/jsch/JSchAddIdentityTest.java b/src/test/java/com/jcraft/jsch/JSchAddIdentityTest.java
new file mode 100644
index 000000000..6661856c1
--- /dev/null
+++ b/src/test/java/com/jcraft/jsch/JSchAddIdentityTest.java
@@ -0,0 +1,136 @@
+package com.jcraft.jsch;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.util.Vector;
+import org.junit.jupiter.api.Test;
+
+public class JSchAddIdentityTest {
+
+ private static final String CERTIFICATES_BASE = "src/test/resources/certificates";
+
+ /**
+ * Tests that addIdentity(prvkey, null, passphrase) auto-discovers the certificate file when a
+ * file named prvkey + "-cert.pub" exists.
+ */
+ @Test
+ void addIdentity_withNullPubkey_shouldAutoDiscoverCertificate() throws Exception {
+ JSch jsch = new JSch();
+
+ // Private key file: root_ed25519_key
+ // Certificate file: root_ed25519_key-cert.pub (should be auto-discovered)
+ String prvkey = CERTIFICATES_BASE + "/ed25519/root_ed25519_key";
+
+ // Call with pubkey = null, should auto-discover the -cert.pub file
+ jsch.addIdentity(prvkey, null, null);
+
+ // Verify that an identity was added
+ IdentityRepository repo = jsch.getIdentityRepository();
+ Vector identities = repo.getIdentities();
+
+ assertNotNull(identities);
+ assertEquals(1, identities.size());
+
+ Identity identity = identities.get(0);
+ // Verify it's a certificate-aware identity by checking the algorithm name
+ String algName = identity.getAlgName();
+ assertTrue(algName.contains("-cert-v01@openssh.com"),
+ "Expected certificate algorithm, got: " + algName);
+ }
+
+ /**
+ * Tests that addIdentity(prvkey, null, passphrase) falls back to regular behavior when no
+ * certificate file exists (only .pub file).
+ */
+ @Test
+ void addIdentity_withNullPubkey_shouldFallbackWhenNoCertificate() throws Exception {
+ JSch jsch = new JSch();
+
+ // Use a key that has only .pub file, not -cert.pub
+ // For this test, we need a key without a certificate
+ // We'll use a temporary approach - create the scenario or use existing non-cert key
+ String prvkey = CERTIFICATES_BASE + "/host/user_keys/id_ecdsa_nistp521";
+
+ // This key has .pub but not -cert.pub, should fall back to IdentityFile
+ jsch.addIdentity(prvkey, null, null);
+
+ // Verify that an identity was added
+ IdentityRepository repo = jsch.getIdentityRepository();
+ Vector identities = repo.getIdentities();
+
+ assertNotNull(identities);
+ assertEquals(1, identities.size());
+
+ Identity identity = identities.get(0);
+ // Should NOT be a certificate algorithm
+ String algName = identity.getAlgName();
+ assertFalse(algName.contains("-cert-v01@openssh.com"),
+ "Expected non-certificate algorithm, got: " + algName);
+ }
+
+ /**
+ * Tests that addIdentity with explicit pubkey path still works correctly for certificate files.
+ */
+ @Test
+ void addIdentity_withExplicitCertPubkey_shouldLoadCertificate() throws Exception {
+ JSch jsch = new JSch();
+
+ String prvkey = CERTIFICATES_BASE + "/ed25519/root_ed25519_key";
+ String pubkey = CERTIFICATES_BASE + "/ed25519/root_ed25519_key-cert.pub";
+
+ jsch.addIdentity(prvkey, pubkey, null);
+
+ IdentityRepository repo = jsch.getIdentityRepository();
+ Vector identities = repo.getIdentities();
+
+ assertNotNull(identities);
+ assertEquals(1, identities.size());
+
+ Identity identity = identities.get(0);
+ String algName = identity.getAlgName();
+ assertEquals("ssh-ed25519-cert-v01@openssh.com", algName);
+ }
+
+ /**
+ * Tests that addIdentity throws an exception when an explicitly provided pubkey file does not
+ * exist. This matches KeyPair.load() behavior.
+ */
+ @Test
+ void addIdentity_withExplicitNonExistentPubkey_shouldThrowException() {
+ JSch jsch = new JSch();
+
+ String prvkey = CERTIFICATES_BASE + "/ed25519/root_ed25519_key";
+ String pubkey = CERTIFICATES_BASE + "/ed25519/non_existent_file.pub";
+
+ assertThrows(JSchException.class, () -> jsch.addIdentity(prvkey, pubkey, null),
+ "Should throw JSchException when explicitly provided pubkey file does not exist");
+ }
+
+ /**
+ * Tests that addIdentity does NOT throw an exception when auto-discovered certificate file does
+ * not exist. This matches KeyPair.load() behavior where auto-discovery failures are silently
+ * ignored.
+ */
+ @Test
+ void addIdentity_withNullPubkeyAndNoCertFile_shouldNotThrowException() throws Exception {
+ JSch jsch = new JSch();
+
+ // This key has only .pub file, no -cert.pub file
+ // Auto-discovery of -cert.pub should fail silently
+ String prvkey = CERTIFICATES_BASE + "/host/user_keys/id_ecdsa_nistp521";
+
+ // Should not throw - auto-discovery failure is silent
+ jsch.addIdentity(prvkey, null, null);
+
+ // Verify that an identity was still added (via IdentityFile fallback)
+ IdentityRepository repo = jsch.getIdentityRepository();
+ Vector identities = repo.getIdentities();
+
+ assertNotNull(identities);
+ assertEquals(1, identities.size());
+ }
+}
diff --git a/src/test/java/com/jcraft/jsch/KeyExchangeTest.java b/src/test/java/com/jcraft/jsch/KeyExchangeTest.java
index 026c39bac..c6fd2dd37 100644
--- a/src/test/java/com/jcraft/jsch/KeyExchangeTest.java
+++ b/src/test/java/com/jcraft/jsch/KeyExchangeTest.java
@@ -247,7 +247,7 @@ public void init(Session session, byte[] V_S, byte[] V_C, byte[] I_S, byte[] I_C
}
@Override
- public boolean doNext(Buffer buf, int sshMessageType) throws Exception {
+ public boolean next(Buffer buf) throws Exception {
throw new UnsupportedOperationException("Not supported");
}
diff --git a/src/test/java/com/jcraft/jsch/OpenSshCertificateBufferTest.java b/src/test/java/com/jcraft/jsch/OpenSshCertificateBufferTest.java
new file mode 100644
index 000000000..62102abca
--- /dev/null
+++ b/src/test/java/com/jcraft/jsch/OpenSshCertificateBufferTest.java
@@ -0,0 +1,205 @@
+package com.jcraft.jsch;
+
+import static org.junit.jupiter.api.Assertions.assertArrayEquals;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+import org.junit.jupiter.api.Test;
+
+/**
+ * Unit tests for {@link OpenSshCertificateBuffer}.
+ *
+ *
+ * This test class verifies the correct behavior of the {@code OpenSshCertificateBuffer} class,
+ * which is responsible for parsing OpenSSH certificate data structures. The tests focus on the
+ * {@link OpenSshCertificateBuffer#getBytes()} method, ensuring proper handling of:
+ *
+ *
+ * - Valid length-prefixed byte arrays
+ * - Empty data arrays
+ * - Invalid data with negative length prefixes
+ * - Invalid data where the length prefix exceeds available data
+ * - Sequential reads from the buffer
+ *
+ *
+ * @see OpenSshCertificateBuffer
+ */
+class OpenSshCertificateBufferTest {
+
+ /**
+ * Helper method to create a buffer with a length-prefixed byte array.
+ *
+ *
+ * The format follows the SSH wire protocol: 4 bytes (big-endian length) followed by the data
+ * bytes.
+ *
+ *
+ * @param data the data bytes to prefix with length
+ * @return byte array containing the length prefix followed by the data
+ */
+ private byte[] createLengthPrefixedData(byte[] data) {
+ byte[] result = new byte[4 + data.length];
+ int len = data.length;
+ result[0] = (byte) ((len >> 24) & 0xff);
+ result[1] = (byte) ((len >> 16) & 0xff);
+ result[2] = (byte) ((len >> 8) & 0xff);
+ result[3] = (byte) (len & 0xff);
+ System.arraycopy(data, 0, result, 4, data.length);
+ return result;
+ }
+
+ /**
+ * Helper method to create a buffer with a specific length prefix that may not match the actual
+ * data length.
+ *
+ *
+ * This is useful for testing error conditions where the length prefix is intentionally incorrect.
+ *
+ *
+ * @param length the length value to encode in the prefix (may differ from actual data length)
+ * @param data the actual data bytes to include after the prefix
+ * @return byte array containing the specified length prefix followed by the data
+ */
+ private byte[] createLengthPrefixedDataWithLength(int length, byte[] data) {
+ byte[] result = new byte[4 + data.length];
+ result[0] = (byte) ((length >> 24) & 0xff);
+ result[1] = (byte) ((length >> 16) & 0xff);
+ result[2] = (byte) ((length >> 8) & 0xff);
+ result[3] = (byte) (length & 0xff);
+ System.arraycopy(data, 0, result, 4, data.length);
+ return result;
+ }
+
+ /**
+ * Tests that {@code getBytes()} correctly reads a valid length-prefixed byte array.
+ *
+ *
+ * Given a buffer containing properly formatted length-prefixed data, the method should return the
+ * exact data bytes without the length prefix.
+ *
+ */
+ @Test
+ void getBytes_validData_returnsCorrectBytes() {
+ byte[] data = {0x01, 0x02, 0x03, 0x04, 0x05};
+ byte[] bufferData = createLengthPrefixedData(data);
+
+ OpenSshCertificateBuffer buffer = new OpenSshCertificateBuffer(bufferData);
+ byte[] result = buffer.getBytes();
+
+ assertArrayEquals(data, result);
+ }
+
+ /**
+ * Tests that {@code getBytes()} correctly handles an empty data array.
+ *
+ *
+ * When the length prefix is zero, the method should return an empty byte array without throwing
+ * any exceptions.
+ *
+ */
+ @Test
+ void getBytes_emptyData_returnsEmptyArray() {
+ byte[] data = {};
+ byte[] bufferData = createLengthPrefixedData(data);
+
+ OpenSshCertificateBuffer buffer = new OpenSshCertificateBuffer(bufferData);
+ byte[] result = buffer.getBytes();
+
+ assertArrayEquals(data, result);
+ assertEquals(0, result.length);
+ }
+
+ /**
+ * Tests that {@code getBytes()} throws an exception when the length prefix is negative.
+ *
+ *
+ * A negative length value (e.g., 0xFFFFFFFF interpreted as -1) indicates malformed certificate
+ * data. The method should throw an {@link IllegalArgumentException} with a descriptive message
+ * rather than attempting to allocate a negative-sized array.
+ *
+ */
+ @Test
+ void getBytes_negativeLength_throwsIllegalArgumentException() {
+ // Create buffer with negative length (-1 = 0xFFFFFFFF in unsigned, but interpreted as -1)
+ byte[] bufferData = {(byte) 0xFF, (byte) 0xFF, (byte) 0xFF, (byte) 0xFF, 0x01, 0x02};
+
+ OpenSshCertificateBuffer buffer = new OpenSshCertificateBuffer(bufferData);
+
+ IllegalArgumentException exception =
+ assertThrows(IllegalArgumentException.class, buffer::getBytes);
+ assertEquals("Invalid length in certificate data: negative length -1", exception.getMessage());
+ }
+
+ /**
+ * Tests that {@code getBytes()} throws an exception when the length prefix exceeds available
+ * data.
+ *
+ *
+ * When the length prefix claims more bytes than are actually available in the buffer, the method
+ * should throw an {@link IllegalArgumentException} rather than reading beyond the buffer bounds
+ * or returning incomplete data.
+ *
+ */
+ @Test
+ void getBytes_lengthExceedsAvailableData_throwsIllegalArgumentException() {
+ // Create buffer claiming 100 bytes but only having 5
+ byte[] actualData = {0x01, 0x02, 0x03, 0x04, 0x05};
+ byte[] bufferData = createLengthPrefixedDataWithLength(100, actualData);
+
+ OpenSshCertificateBuffer buffer = new OpenSshCertificateBuffer(bufferData);
+
+ IllegalArgumentException exception =
+ assertThrows(IllegalArgumentException.class, buffer::getBytes);
+ assertEquals("Invalid length in certificate data: requested 100 bytes but only 5 available",
+ exception.getMessage());
+ }
+
+ /**
+ * Tests that {@code getBytes()} succeeds when the length prefix exactly matches available data.
+ *
+ *
+ * This is a boundary condition test to ensure that the method correctly handles the case where
+ * all remaining buffer data is consumed by a single read operation.
+ *
+ */
+ @Test
+ void getBytes_lengthExactlyMatchesAvailableData_succeeds() {
+ byte[] data = {0x0A, 0x0B, 0x0C};
+ byte[] bufferData = createLengthPrefixedData(data);
+
+ OpenSshCertificateBuffer buffer = new OpenSshCertificateBuffer(bufferData);
+ byte[] result = buffer.getBytes();
+
+ assertArrayEquals(data, result);
+ }
+
+ /**
+ * Tests that multiple sequential {@code getBytes()} calls work correctly.
+ *
+ *
+ * OpenSSH certificates contain multiple length-prefixed fields. This test verifies that the
+ * buffer correctly advances its read position after each call, allowing sequential fields to be
+ * read independently.
+ *
+ */
+ @Test
+ void getBytes_multipleReads_worksCorrectly() {
+ // Create buffer with two length-prefixed arrays
+ byte[] data1 = {0x01, 0x02};
+ byte[] data2 = {0x03, 0x04, 0x05};
+ byte[] prefixed1 = createLengthPrefixedData(data1);
+ byte[] prefixed2 = createLengthPrefixedData(data2);
+
+ byte[] combined = new byte[prefixed1.length + prefixed2.length];
+ System.arraycopy(prefixed1, 0, combined, 0, prefixed1.length);
+ System.arraycopy(prefixed2, 0, combined, prefixed1.length, prefixed2.length);
+
+ OpenSshCertificateBuffer buffer = new OpenSshCertificateBuffer(combined);
+
+ byte[] result1 = buffer.getBytes();
+ byte[] result2 = buffer.getBytes();
+
+ assertArrayEquals(data1, result1);
+ assertArrayEquals(data2, result2);
+ }
+}
diff --git a/src/test/java/com/jcraft/jsch/OpenSshCertificateHostKeyVerifierTest.java b/src/test/java/com/jcraft/jsch/OpenSshCertificateHostKeyVerifierTest.java
new file mode 100644
index 000000000..0325a9769
--- /dev/null
+++ b/src/test/java/com/jcraft/jsch/OpenSshCertificateHostKeyVerifierTest.java
@@ -0,0 +1,171 @@
+package com.jcraft.jsch;
+
+import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.Map;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Unit tests for OpenSshCertificateHostKeyVerifier focusing on certificate validation edge cases.
+ */
+public class OpenSshCertificateHostKeyVerifierTest {
+
+ // ==================== Tests for critical options rejection ====================
+
+ /**
+ * Test that a certificate with critical options is rejected.
+ */
+ @Test
+ public void testCheckHostCertificate_withCriticalOptions_shouldReject() throws Exception {
+ Map criticalOptions = new HashMap<>();
+ criticalOptions.put("force-command", "/bin/false");
+
+ OpenSshCertificate cert =
+ createValidHostCertificateBuilder().criticalOptions(criticalOptions).build();
+
+ // Verify that isEmpty correctly identifies non-empty critical options
+ assertTrue(!OpenSshCertificateUtil.isEmpty(cert.getCriticalOptions()),
+ "Critical options should not be empty");
+ }
+
+ /**
+ * Test that a certificate with multiple critical options is detected.
+ */
+ @Test
+ public void testCheckHostCertificate_withMultipleCriticalOptions() {
+ Map criticalOptions = new HashMap<>();
+ criticalOptions.put("force-command", "/bin/false");
+ criticalOptions.put("source-address", "192.168.1.0/24");
+
+ OpenSshCertificate cert =
+ createValidHostCertificateBuilder().criticalOptions(criticalOptions).build();
+
+ assertEquals(2, cert.getCriticalOptions().size(), "Should have 2 critical options");
+ }
+
+ /**
+ * Test that a certificate with empty critical options is accepted.
+ */
+ @Test
+ public void testCheckHostCertificate_withEmptyCriticalOptions() {
+ OpenSshCertificate cert =
+ createValidHostCertificateBuilder().criticalOptions(Collections.emptyMap()).build();
+
+ assertTrue(OpenSshCertificateUtil.isEmpty(cert.getCriticalOptions()),
+ "Critical options should be empty");
+ }
+
+ /**
+ * Test that a certificate with null critical options is accepted.
+ */
+ @Test
+ public void testCheckHostCertificate_withNullCriticalOptions() {
+ OpenSshCertificate cert = createValidHostCertificateBuilder().criticalOptions(null).build();
+
+ assertTrue(OpenSshCertificateUtil.isEmpty(cert.getCriticalOptions()),
+ "Null critical options should be treated as empty");
+ }
+
+ // ==================== Tests for certificate type validation ====================
+
+ /**
+ * Test that isHostCertificate returns true for host certificate type.
+ */
+ @Test
+ public void testIsHostCertificate_hostType() {
+ OpenSshCertificate cert = createValidHostCertificateBuilder().build();
+
+ assertTrue(cert.isHostCertificate(), "Should be identified as host certificate");
+ }
+
+ /**
+ * Test that isHostCertificate returns false for user certificate type.
+ */
+ @Test
+ public void testIsHostCertificate_userType() {
+ OpenSshCertificate cert = createValidUserCertificateBuilder().build();
+
+ assertTrue(!cert.isHostCertificate(), "Should not be identified as host certificate");
+ }
+
+ // ==================== Tests for principal validation ====================
+
+ /**
+ * Test that empty principals list is detected.
+ */
+ @Test
+ public void testPrincipals_emptyList() {
+ OpenSshCertificate cert =
+ createValidHostCertificateBuilder().principals(Collections.emptyList()).build();
+
+ assertTrue(cert.getPrincipals().isEmpty(), "Principals should be empty");
+ }
+
+ /**
+ * Test that null principals list is handled.
+ */
+ @Test
+ public void testPrincipals_nullList() {
+ OpenSshCertificate cert = createValidHostCertificateBuilder().principals(null).build();
+
+ assertTrue(cert.getPrincipals() == null, "Principals should be null");
+ }
+
+ /**
+ * Test that multiple principals are correctly stored.
+ */
+ @Test
+ public void testPrincipals_multipleValues() {
+ OpenSshCertificate cert = createValidHostCertificateBuilder()
+ .principals(Arrays.asList("host1.example.com", "host2.example.com", "10.0.0.1")).build();
+
+ assertEquals(3, cert.getPrincipals().size(), "Should have 3 principals");
+ assertTrue(cert.getPrincipals().contains("host1.example.com"), "Should contain host1");
+ assertTrue(cert.getPrincipals().contains("host2.example.com"), "Should contain host2");
+ assertTrue(cert.getPrincipals().contains("10.0.0.1"), "Should contain IP");
+ }
+
+ // ==================== Helper methods ====================
+
+ // Dummy byte arrays for required fields in tests
+ private static final byte[] DUMMY_NONCE = new byte[] {1, 2, 3, 4, 5, 6, 7, 8};
+ private static final byte[] DUMMY_PUBLIC_KEY =
+ new byte[] {0, 0, 0, 7, 's', 's', 'h', '-', 'r', 's', 'a', 0, 0, 0, 1, 35, 0, 0, 0, 1, 0};
+ private static final byte[] DUMMY_SIGNATURE_KEY =
+ new byte[] {0, 0, 0, 7, 's', 's', 'h', '-', 'r', 's', 'a', 0, 0, 0, 1, 35, 0, 0, 0, 1, 0};
+ private static final byte[] DUMMY_SIGNATURE =
+ new byte[] {0, 0, 0, 7, 's', 's', 'h', '-', 'r', 's', 'a', 0, 0, 0, 4, 1, 2, 3, 4};
+ private static final byte[] DUMMY_MESSAGE = new byte[] {1, 2, 3, 4, 5, 6, 7, 8, 9, 10};
+
+ /**
+ * Creates a builder pre-configured with valid host certificate defaults.
+ */
+ private OpenSshCertificate.Builder createValidHostCertificateBuilder() {
+ long now = java.time.Instant.now().getEpochSecond();
+ return new OpenSshCertificate.Builder().keyType("ssh-rsa-cert-v01@openssh.com")
+ .nonce(DUMMY_NONCE).certificatePublicKey(DUMMY_PUBLIC_KEY)
+ .type(OpenSshCertificate.SSH2_CERT_TYPE_HOST).id("test-certificate")
+ .principals(Arrays.asList("localhost")).validAfter(now - 3600).validBefore(now + 3600)
+ .criticalOptions(Collections.emptyMap()).extensions(Collections.emptyMap())
+ .signatureKey(DUMMY_SIGNATURE_KEY).signature(DUMMY_SIGNATURE).message(DUMMY_MESSAGE);
+ }
+
+ /**
+ * Creates a builder pre-configured with valid user certificate defaults.
+ */
+ private OpenSshCertificate.Builder createValidUserCertificateBuilder() {
+ long now = java.time.Instant.now().getEpochSecond();
+ return new OpenSshCertificate.Builder().keyType("ssh-rsa-cert-v01@openssh.com")
+ .nonce(DUMMY_NONCE).certificatePublicKey(DUMMY_PUBLIC_KEY)
+ .type(OpenSshCertificate.SSH2_CERT_TYPE_USER).id("test-certificate")
+ .principals(Arrays.asList("testuser")).validAfter(now - 3600).validBefore(now + 3600)
+ .criticalOptions(Collections.emptyMap()).extensions(Collections.emptyMap())
+ .signatureKey(DUMMY_SIGNATURE_KEY).signature(DUMMY_SIGNATURE).message(DUMMY_MESSAGE);
+ }
+}
diff --git a/src/test/java/com/jcraft/jsch/OpenSshCertificateKeyTypesTest.java b/src/test/java/com/jcraft/jsch/OpenSshCertificateKeyTypesTest.java
new file mode 100644
index 000000000..82eebf971
--- /dev/null
+++ b/src/test/java/com/jcraft/jsch/OpenSshCertificateKeyTypesTest.java
@@ -0,0 +1,146 @@
+package com.jcraft.jsch;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.CsvSource;
+import org.junit.jupiter.params.provider.NullAndEmptySource;
+import org.junit.jupiter.params.provider.ValueSource;
+
+/**
+ * Unit tests for {@link OpenSshCertificateKeyTypes}.
+ */
+public class OpenSshCertificateKeyTypesTest {
+
+ // ==================== Tests for isCertificateKeyType ====================
+
+ @ParameterizedTest
+ @ValueSource(strings = {"ssh-rsa-cert-v01@openssh.com", "ssh-dss-cert-v01@openssh.com",
+ "ecdsa-sha2-nistp256-cert-v01@openssh.com", "ecdsa-sha2-nistp384-cert-v01@openssh.com",
+ "ecdsa-sha2-nistp521-cert-v01@openssh.com", "ssh-ed25519-cert-v01@openssh.com",
+ "ssh-ed448-cert-v01@openssh.com", "rsa-sha2-256-cert-v01@openssh.com",
+ "rsa-sha2-512-cert-v01@openssh.com"})
+ public void testIsCertificateKeyType_validCertTypes(String keyType) {
+ assertTrue(OpenSshCertificateKeyTypes.isCertificateKeyType(keyType),
+ "Should recognize " + keyType + " as certificate type");
+ }
+
+ @ParameterizedTest
+ @ValueSource(strings = {"ssh-rsa", "ssh-dss", "ecdsa-sha2-nistp256", "ssh-ed25519", "ssh-ed448",
+ "rsa-sha2-256", "rsa-sha2-512", "unknown-cert-v01@openssh.com",
+ "ssh-rsa-cert-v02@openssh.com"})
+ public void testIsCertificateKeyType_nonCertTypes(String keyType) {
+ assertFalse(OpenSshCertificateKeyTypes.isCertificateKeyType(keyType),
+ "Should not recognize " + keyType + " as certificate type");
+ }
+
+ @Test
+ public void testIsCertificateKeyType_null() {
+ assertFalse(OpenSshCertificateKeyTypes.isCertificateKeyType(null),
+ "Should return false for null");
+ }
+
+ // ==================== Tests for getBaseKeyType ====================
+
+ @ParameterizedTest
+ @CsvSource({"ssh-rsa-cert-v01@openssh.com,ssh-rsa", "ssh-dss-cert-v01@openssh.com,ssh-dss",
+ "ecdsa-sha2-nistp256-cert-v01@openssh.com,ecdsa-sha2-nistp256",
+ "ecdsa-sha2-nistp384-cert-v01@openssh.com,ecdsa-sha2-nistp384",
+ "ecdsa-sha2-nistp521-cert-v01@openssh.com,ecdsa-sha2-nistp521",
+ "ssh-ed25519-cert-v01@openssh.com,ssh-ed25519", "ssh-ed448-cert-v01@openssh.com,ssh-ed448",
+ "rsa-sha2-256-cert-v01@openssh.com,rsa-sha2-256",
+ "rsa-sha2-512-cert-v01@openssh.com,rsa-sha2-512"})
+ public void testGetBaseKeyType_certTypes(String certType, String expectedBaseType) {
+ assertEquals(expectedBaseType, OpenSshCertificateKeyTypes.getBaseKeyType(certType));
+ }
+
+ @ParameterizedTest
+ @ValueSource(strings = {"ssh-rsa", "ssh-dss", "ecdsa-sha2-nistp256", "ssh-ed25519"})
+ public void testGetBaseKeyType_nonCertTypes(String keyType) {
+ assertEquals(keyType, OpenSshCertificateKeyTypes.getBaseKeyType(keyType),
+ "Should return original key type for non-certificate types");
+ }
+
+ @Test
+ public void testGetBaseKeyType_null() {
+ assertNull(OpenSshCertificateKeyTypes.getBaseKeyType(null),
+ "Should return null for null input");
+ }
+
+ @ParameterizedTest
+ @NullAndEmptySource
+ public void testGetBaseKeyType_nullOrEmpty(String keyType) {
+ assertNull(OpenSshCertificateKeyTypes.getBaseKeyType(keyType),
+ "Should return null for null or empty input");
+ }
+
+ @ParameterizedTest
+ @ValueSource(strings = {" ", "\t", "\n"})
+ public void testGetBaseKeyType_blankReturnsOriginal(String keyType) {
+ assertEquals(keyType, OpenSshCertificateKeyTypes.getBaseKeyType(keyType),
+ "Should return original string for blank (non-empty) input");
+ }
+
+ // ==================== Tests for constants ====================
+
+ @Test
+ public void testConstantsHaveCorrectValues() {
+ assertEquals("ssh-rsa-cert-v01@openssh.com", OpenSshCertificateKeyTypes.SSH_RSA_CERT_V01);
+ assertEquals("ssh-dss-cert-v01@openssh.com", OpenSshCertificateKeyTypes.SSH_DSS_CERT_V01);
+ assertEquals("ecdsa-sha2-nistp256-cert-v01@openssh.com",
+ OpenSshCertificateKeyTypes.ECDSA_SHA2_NISTP256_CERT_V01);
+ assertEquals("ecdsa-sha2-nistp384-cert-v01@openssh.com",
+ OpenSshCertificateKeyTypes.ECDSA_SHA2_NISTP384_CERT_V01);
+ assertEquals("ecdsa-sha2-nistp521-cert-v01@openssh.com",
+ OpenSshCertificateKeyTypes.ECDSA_SHA2_NISTP521_CERT_V01);
+ assertEquals("ssh-ed25519-cert-v01@openssh.com",
+ OpenSshCertificateKeyTypes.SSH_ED25519_CERT_V01);
+ assertEquals("ssh-ed448-cert-v01@openssh.com", OpenSshCertificateKeyTypes.SSH_ED448_CERT_V01);
+ assertEquals("-cert-v01@openssh.com", OpenSshCertificateKeyTypes.CERT_SUFFIX);
+ }
+
+ // ==================== Tests for getCertificateKeyType ====================
+
+ @ParameterizedTest
+ @CsvSource({"ssh-rsa,ssh-rsa-cert-v01@openssh.com", "ssh-dss,ssh-dss-cert-v01@openssh.com",
+ "ecdsa-sha2-nistp256,ecdsa-sha2-nistp256-cert-v01@openssh.com",
+ "ecdsa-sha2-nistp384,ecdsa-sha2-nistp384-cert-v01@openssh.com",
+ "ecdsa-sha2-nistp521,ecdsa-sha2-nistp521-cert-v01@openssh.com",
+ "ssh-ed25519,ssh-ed25519-cert-v01@openssh.com", "ssh-ed448,ssh-ed448-cert-v01@openssh.com",
+ "rsa-sha2-256,rsa-sha2-256-cert-v01@openssh.com",
+ "rsa-sha2-512,rsa-sha2-512-cert-v01@openssh.com"})
+ public void testGetCertificateKeyType_knownAlgorithms(String baseAlg, String expectedCertType) {
+ assertEquals(expectedCertType, OpenSshCertificateKeyTypes.getCertificateKeyType(baseAlg));
+ }
+
+ @ParameterizedTest
+ @ValueSource(strings = {"unknown-algorithm", "ssh-rsa-cert-v01@openssh.com", "aes256-ctr"})
+ public void testGetCertificateKeyType_unknownAlgorithms(String algorithm) {
+ assertNull(OpenSshCertificateKeyTypes.getCertificateKeyType(algorithm),
+ "Should return null for unknown or already-certificate algorithms");
+ }
+
+ @Test
+ public void testGetCertificateKeyType_null() {
+ assertNull(OpenSshCertificateKeyTypes.getCertificateKeyType(null),
+ "Should return null for null input");
+ }
+
+ @Test
+ public void testGetCertificateKeyType_roundTrip() {
+ // Verify that getCertificateKeyType and getBaseKeyType are inverse operations
+ String[] baseAlgorithms =
+ {"ssh-rsa", "ssh-dss", "ecdsa-sha2-nistp256", "ssh-ed25519", "ssh-ed448"};
+ for (String base : baseAlgorithms) {
+ String certType = OpenSshCertificateKeyTypes.getCertificateKeyType(base);
+ assertNotNull(certType, "Certificate type should not be null for " + base);
+ String recoveredBase = OpenSshCertificateKeyTypes.getBaseKeyType(certType);
+ assertEquals(base, recoveredBase, "Round-trip should recover original base algorithm");
+ }
+ }
+}
diff --git a/src/test/java/com/jcraft/jsch/OpenSshCertificateUtilTest.java b/src/test/java/com/jcraft/jsch/OpenSshCertificateUtilTest.java
index 313510e26..d3dc8c4ed 100644
--- a/src/test/java/com/jcraft/jsch/OpenSshCertificateUtilTest.java
+++ b/src/test/java/com/jcraft/jsch/OpenSshCertificateUtilTest.java
@@ -1,6 +1,7 @@
package com.jcraft.jsch;
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
+import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
@@ -150,7 +151,6 @@ public void testExtractSpaceDelimitedString_realWorldCertificate() {
OpenSshCertificateUtil.extractSpaceDelimitedString(input, 2));
}
-
@Test
public void testExtractSpaceDelimitedString_lastFieldNoTrailingWhitespace() {
byte[] input = "field1 field2".getBytes(StandardCharsets.UTF_8);
@@ -199,7 +199,7 @@ public void testIsCertificateSignedByTrustedCA_trustedCAFound() throws Exception
// Execute
boolean result =
- OpenSshCertificateUtil.isCertificateSignedByTrustedCA(knownHosts, host, base64CaPublicKey);
+ OpenSshCertificateUtil.isCertificateSignedByTrustedCA(knownHosts, host, keyBytes);
// Verify
assertTrue(result, "Should return true when trusted CA is found");
@@ -218,15 +218,18 @@ public void testIsCertificateSignedByTrustedCA_caKeyMismatch() throws Exception
String differentCaKey = "AAAAB3NzaC1yc2EAAAADAQABAAABDIFFERENT==";
// Create a CA with different key
- byte[] keyBytes = Util.fromBase64(Util.str2byte(differentCaKey), 0, differentCaKey.length());
+ byte[] differentKeyBytes =
+ Util.fromBase64(Util.str2byte(differentCaKey), 0, differentCaKey.length());
HostKey caHostKey =
- new HostKey("@cert-authority", "*.example.com", HostKey.SSHRSA, keyBytes, null);
+ new HostKey("@cert-authority", "*.example.com", HostKey.SSHRSA, differentKeyBytes, null);
knownHosts.add(caHostKey, null);
- // Execute
+ // Execute - pass the original (non-matching) key bytes
+ byte[] caPublicKeyBytes =
+ Util.fromBase64(Util.str2byte(base64CaPublicKey), 0, base64CaPublicKey.length());
boolean result =
- OpenSshCertificateUtil.isCertificateSignedByTrustedCA(knownHosts, host, base64CaPublicKey);
+ OpenSshCertificateUtil.isCertificateSignedByTrustedCA(knownHosts, host, caPublicKeyBytes);
// Verify
assertFalse(result, "Should return false when CA key doesn't match");
@@ -259,7 +262,7 @@ public void testIsCertificateSignedByTrustedCA_caIsRevoked() throws Exception {
// Execute
boolean result =
- OpenSshCertificateUtil.isCertificateSignedByTrustedCA(knownHosts, host, base64CaPublicKey);
+ OpenSshCertificateUtil.isCertificateSignedByTrustedCA(knownHosts, host, keyBytes);
// Verify
assertFalse(result, "Should return false when CA is revoked (fail-closed security)");
@@ -285,7 +288,7 @@ public void testIsCertificateSignedByTrustedCA_hostPatternMismatch() throws Exce
// Execute
boolean result =
- OpenSshCertificateUtil.isCertificateSignedByTrustedCA(knownHosts, host, base64CaPublicKey);
+ OpenSshCertificateUtil.isCertificateSignedByTrustedCA(knownHosts, host, keyBytes);
// Verify
assertFalse(result, "Should return false when host pattern doesn't match");
@@ -302,9 +305,12 @@ public void testIsCertificateSignedByTrustedCA_emptyRepository() throws JSchExce
String host = "example.com";
String base64CaPublicKey = "AAAAB3NzaC1yc2EAAAADAQABAAABAQ==";
+ byte[] caPublicKeyBytes =
+ Util.fromBase64(Util.str2byte(base64CaPublicKey), 0, base64CaPublicKey.length());
+
// Execute
boolean result =
- OpenSshCertificateUtil.isCertificateSignedByTrustedCA(knownHosts, host, base64CaPublicKey);
+ OpenSshCertificateUtil.isCertificateSignedByTrustedCA(knownHosts, host, caPublicKeyBytes);
// Verify
assertFalse(result, "Should return false when repository is empty");
@@ -338,7 +344,7 @@ public void testIsCertificateSignedByTrustedCA_multipleCAsOneMatches() throws Ex
// Execute
boolean result =
- OpenSshCertificateUtil.isCertificateSignedByTrustedCA(knownHosts, host, base64CaPublicKey);
+ OpenSshCertificateUtil.isCertificateSignedByTrustedCA(knownHosts, host, matchingKeyBytes);
// Verify
assertTrue(result, "Should return true when one of multiple CAs matches (anyMatch behavior)");
@@ -369,7 +375,7 @@ public void testIsCertificateSignedByTrustedCA_ignoresNonCaEntries() throws Exce
// Execute
boolean result =
- OpenSshCertificateUtil.isCertificateSignedByTrustedCA(knownHosts, host, base64CaPublicKey);
+ OpenSshCertificateUtil.isCertificateSignedByTrustedCA(knownHosts, host, keyBytes);
// Verify
assertFalse(result, "Should ignore entries without @cert-authority marker");
@@ -397,7 +403,7 @@ public void testIsCertificateSignedByTrustedCA_wildcardHostPattern() throws Exce
// Execute
boolean result =
- OpenSshCertificateUtil.isCertificateSignedByTrustedCA(knownHosts, host, base64CaPublicKey);
+ OpenSshCertificateUtil.isCertificateSignedByTrustedCA(knownHosts, host, keyBytes);
// Verify
assertTrue(result, "Should match wildcard host pattern *.example.com with sub.example.com");
@@ -424,7 +430,7 @@ public void testIsCertificateSignedByTrustedCA_ed25519KeyType() throws Exception
// Execute
boolean result =
- OpenSshCertificateUtil.isCertificateSignedByTrustedCA(knownHosts, host, base64CaPublicKey);
+ OpenSshCertificateUtil.isCertificateSignedByTrustedCA(knownHosts, host, keyBytes);
// Verify
assertTrue(result, "Should work with Ed25519 key type");
@@ -442,6 +448,9 @@ public void testIsCertificateSignedByTrustedCA_caWithNullKey() throws Exception
String host = "example.com";
String base64CaPublicKey = "AAAAB3NzaC1yc2EAAAADAQABAAABAQ==";
+ byte[] caPublicKeyBytes =
+ Util.fromBase64(Util.str2byte(base64CaPublicKey), 0, base64CaPublicKey.length());
+
// Create CA with null key (malformed entry)
HostKey caHostKeyWithNullKey =
new HostKey("@cert-authority", "*.example.com", HostKey.SSHRSA, null, null);
@@ -450,7 +459,7 @@ public void testIsCertificateSignedByTrustedCA_caWithNullKey() throws Exception
// Execute
boolean result =
- OpenSshCertificateUtil.isCertificateSignedByTrustedCA(knownHosts, host, base64CaPublicKey);
+ OpenSshCertificateUtil.isCertificateSignedByTrustedCA(knownHosts, host, caPublicKeyBytes);
// Verify
assertFalse(result, "Should return false when CA has null key (fail-closed security)");
@@ -493,10 +502,419 @@ public void testIsCertificateSignedByTrustedCA_complexScenario() throws Exceptio
// Execute
boolean result =
- OpenSshCertificateUtil.isCertificateSignedByTrustedCA(knownHosts, host, validCaKey);
+ OpenSshCertificateUtil.isCertificateSignedByTrustedCA(knownHosts, host, validKeyBytes);
// Verify
assertTrue(result,
"Should find the valid CA among multiple entries, ignoring revoked/different/null entries");
}
+
+ // ==================== Tests for filterUnavailableCertTypes ====================
+
+ /**
+ * Test that filterUnavailableCertTypes only removes ssh-rsa-cert when ssh-rsa is unavailable,
+ * leaving rsa-sha2-256-cert and rsa-sha2-512-cert available.
+ */
+ @Test
+ public void testFilterUnavailableCertTypes_sshRsaUnavailable_shouldOnlyRemoveSshRsaCert() {
+ // Setup: server_host_key proposal with all RSA cert types
+ String serverHostKey =
+ "ssh-ed25519-cert-v01@openssh.com,ssh-rsa-cert-v01@openssh.com,rsa-sha2-256-cert-v01@openssh.com,rsa-sha2-512-cert-v01@openssh.com";
+
+ // Only ssh-rsa (SHA1) is unavailable
+ String[] unavailableSignatures = {"ssh-rsa"};
+
+ // Execute
+ String result =
+ OpenSshCertificateUtil.filterUnavailableCertTypes(serverHostKey, unavailableSignatures);
+
+ // Verify: ssh-rsa-cert removed, but SHA2 variants remain
+ assertTrue(result.contains("ssh-ed25519-cert-v01@openssh.com"),
+ "ssh-ed25519-cert should remain");
+ assertFalse(result.contains("ssh-rsa-cert-v01@openssh.com"), "ssh-rsa-cert should be removed");
+ assertTrue(result.contains("rsa-sha2-256-cert-v01@openssh.com"),
+ "rsa-sha2-256-cert should remain when only ssh-rsa is unavailable");
+ assertTrue(result.contains("rsa-sha2-512-cert-v01@openssh.com"),
+ "rsa-sha2-512-cert should remain when only ssh-rsa is unavailable");
+ }
+
+ /**
+ * Test that filterUnavailableCertTypes removes rsa-sha2-256-cert when rsa-sha2-256 is
+ * unavailable.
+ */
+ @Test
+ public void testFilterUnavailableCertTypes_rsaSha2256Unavailable_shouldRemoveOnlyThatCert() {
+ String serverHostKey =
+ "ssh-rsa-cert-v01@openssh.com,rsa-sha2-256-cert-v01@openssh.com,rsa-sha2-512-cert-v01@openssh.com";
+
+ String[] unavailableSignatures = {"rsa-sha2-256"};
+
+ String result =
+ OpenSshCertificateUtil.filterUnavailableCertTypes(serverHostKey, unavailableSignatures);
+
+ assertTrue(result.contains("ssh-rsa-cert-v01@openssh.com"), "ssh-rsa-cert should remain");
+ assertFalse(result.contains("rsa-sha2-256-cert-v01@openssh.com"),
+ "rsa-sha2-256-cert should be removed");
+ assertTrue(result.contains("rsa-sha2-512-cert-v01@openssh.com"),
+ "rsa-sha2-512-cert should remain");
+ }
+
+ /**
+ * Test that filterUnavailableCertTypes removes all RSA certs when all RSA algorithms are
+ * unavailable.
+ */
+ @Test
+ public void testFilterUnavailableCertTypes_allRsaUnavailable_shouldRemoveAllRsaCerts() {
+ String serverHostKey =
+ "ssh-ed25519-cert-v01@openssh.com,ssh-rsa-cert-v01@openssh.com,rsa-sha2-256-cert-v01@openssh.com,rsa-sha2-512-cert-v01@openssh.com";
+
+ String[] unavailableSignatures = {"ssh-rsa", "rsa-sha2-256", "rsa-sha2-512"};
+
+ String result =
+ OpenSshCertificateUtil.filterUnavailableCertTypes(serverHostKey, unavailableSignatures);
+
+ assertTrue(result.contains("ssh-ed25519-cert-v01@openssh.com"),
+ "ssh-ed25519-cert should remain");
+ assertFalse(result.contains("ssh-rsa-cert-v01@openssh.com"), "ssh-rsa-cert should be removed");
+ assertFalse(result.contains("rsa-sha2-256-cert-v01@openssh.com"),
+ "rsa-sha2-256-cert should be removed");
+ assertFalse(result.contains("rsa-sha2-512-cert-v01@openssh.com"),
+ "rsa-sha2-512-cert should be removed");
+ }
+
+ /**
+ * Test that filterUnavailableCertTypes returns serverHostKey unchanged when unavailableSignatures
+ * is null.
+ */
+ @Test
+ public void testFilterUnavailableCertTypes_nullUnavailable_shouldReturnUnchanged() {
+ String serverHostKey = "ssh-ed25519-cert-v01@openssh.com,ssh-rsa-cert-v01@openssh.com";
+
+ String result = OpenSshCertificateUtil.filterUnavailableCertTypes(serverHostKey, null);
+
+ assertEquals(serverHostKey, result);
+ }
+
+ /**
+ * Test that filterUnavailableCertTypes returns serverHostKey unchanged when unavailableSignatures
+ * is empty.
+ */
+ @Test
+ public void testFilterUnavailableCertTypes_emptyUnavailable_shouldReturnUnchanged() {
+ String serverHostKey = "ssh-ed25519-cert-v01@openssh.com,ssh-rsa-cert-v01@openssh.com";
+
+ String result =
+ OpenSshCertificateUtil.filterUnavailableCertTypes(serverHostKey, new String[] {});
+
+ assertEquals(serverHostKey, result);
+ }
+
+ // ==================== Tests for isValidNow (expired/not-yet-valid certificates)
+ // ====================
+
+ /**
+ * Test that isValidNow returns false for an expired certificate.
+ */
+ @Test
+ public void testIsValidNow_expiredCertificate() {
+ long now = 10000L;
+ OpenSshCertificate cert = createValidCertificateBuilder().validAfter(now - 7200) // Valid from
+ // 2 hours ago
+ .validBefore(now - 3600) // Expired 1 hour ago
+ .build();
+
+ assertFalse(OpenSshCertificateUtil.isValidNow(cert, now), "Certificate should be expired");
+ }
+
+ /**
+ * Test that isValidNow returns false for a certificate not yet valid.
+ */
+ @Test
+ public void testIsValidNow_notYetValidCertificate() {
+ long now = 10000L;
+ OpenSshCertificate cert = createValidCertificateBuilder().validAfter(now + 3600) // Valid from
+ // 1 hour in
+ // the future
+ .validBefore(now + 7200) // Expires 2 hours in the future
+ .build();
+
+ assertFalse(OpenSshCertificateUtil.isValidNow(cert, now),
+ "Certificate should not be valid yet");
+ }
+
+ /**
+ * Test that isValidNow returns true for a currently valid certificate.
+ */
+ @Test
+ public void testIsValidNow_currentlyValidCertificate() {
+ long now = 10000L;
+ OpenSshCertificate cert = createValidCertificateBuilder().validAfter(now - 3600) // Valid from
+ // 1 hour ago
+ .validBefore(now + 3600) // Expires 1 hour from now
+ .build();
+
+ assertTrue(OpenSshCertificateUtil.isValidNow(cert, now), "Certificate should be valid");
+ }
+
+ /**
+ * Test that isValidNow handles boundary condition: validAfter equals current time.
+ */
+ @Test
+ public void testIsValidNow_validAfterEqualsNow() {
+ long now = 10000L;
+ OpenSshCertificate cert = createValidCertificateBuilder().validAfter(now) // Valid from now
+ .validBefore(now + 3600) // Expires 1 hour from now
+ .build();
+
+ assertTrue(OpenSshCertificateUtil.isValidNow(cert, now),
+ "Certificate should be valid when validAfter equals now");
+ }
+
+ /**
+ * Test that isValidNow handles boundary condition: validBefore equals current time.
+ */
+ @Test
+ public void testIsValidNow_validBeforeEqualsNow() {
+ long now = 10000L;
+ OpenSshCertificate cert = createValidCertificateBuilder().validAfter(now - 3600) // Valid from
+ // 1 hour ago
+ .validBefore(now) // Expires now
+ .build();
+
+ assertFalse(OpenSshCertificateUtil.isValidNow(cert, now),
+ "Certificate should be expired when validBefore equals now");
+ }
+
+ /**
+ * Test isValidNow with maximum validity (forever valid certificate).
+ */
+ @Test
+ public void testIsValidNow_foreverValidCertificate() {
+ long now = 10000L;
+ OpenSshCertificate cert =
+ createValidCertificateBuilder().validAfter(OpenSshCertificate.MIN_VALIDITY) // From epoch
+ .validBefore(OpenSshCertificate.MAX_VALIDITY) // Forever (max unsigned long)
+ .build();
+
+ assertTrue(OpenSshCertificateUtil.isValidNow(cert, now),
+ "Certificate with max validity should be valid");
+ }
+
+ // ==================== Tests for serial number edge cases ====================
+
+ /**
+ * Test certificate with maximum unsigned long serial number.
+ */
+ @Test
+ public void testCertificate_maxSerialNumber() {
+ // Maximum unsigned 64-bit value: 0xFFFFFFFFFFFFFFFF
+ long maxSerial = 0xFFFF_FFFF_FFFF_FFFFL;
+ OpenSshCertificate cert = createValidCertificateBuilder().serial(maxSerial).build();
+
+ assertEquals(maxSerial, cert.getSerial(), "Should handle maximum serial number");
+ }
+
+ /**
+ * Test certificate with zero serial number.
+ */
+ @Test
+ public void testCertificate_zeroSerialNumber() {
+ OpenSshCertificate cert = createValidCertificateBuilder().serial(0L).build();
+
+ assertEquals(0L, cert.getSerial(), "Should handle zero serial number");
+ }
+
+ /**
+ * Test certificate with serial number that appears negative when treated as signed long.
+ */
+ @Test
+ public void testCertificate_largeSerialNumberAppearsNegative() {
+ // This value is negative when treated as signed long, but valid as unsigned
+ long largeSerial = 0x8000_0000_0000_0001L; // -9223372036854775807 as signed
+ OpenSshCertificate cert = createValidCertificateBuilder().serial(largeSerial).build();
+
+ assertEquals(largeSerial, cert.getSerial(), "Should handle large serial that appears negative");
+ // Verify unsigned comparison works
+ assertTrue(Long.compareUnsigned(largeSerial, 0L) > 0,
+ "Serial should be positive when compared unsigned");
+ }
+
+ // ==================== Tests for toDateString edge cases ====================
+
+ /**
+ * Test toDateString with negative timestamp (represents infinity).
+ */
+ @Test
+ public void testToDateString_negativeTimestamp() {
+ String result = OpenSshCertificateUtil.toDateString(-1L);
+ assertEquals("infinity", result, "Negative timestamp should return 'infinity'");
+ }
+
+ /**
+ * Test toDateString with minimum negative timestamp.
+ */
+ @Test
+ public void testToDateString_minLongValue() {
+ String result = OpenSshCertificateUtil.toDateString(Long.MIN_VALUE);
+ assertEquals("infinity", result, "Long.MIN_VALUE should return 'infinity'");
+ }
+
+ /**
+ * Test toDateString with zero timestamp (epoch).
+ */
+ @Test
+ public void testToDateString_zeroTimestamp() {
+ String result = OpenSshCertificateUtil.toDateString(0L);
+ // Should return a date string for epoch (Jan 1, 1970)
+ assertFalse(result.equals("infinity"), "Zero timestamp should not return 'infinity'");
+ assertTrue(result.contains("1970"), "Zero timestamp should represent 1970");
+ }
+
+ /**
+ * Test toDateString with positive timestamp.
+ */
+ @Test
+ public void testToDateString_positiveTimestamp() {
+ // 1704067200 = Jan 1, 2024 00:00:00 UTC
+ String result = OpenSshCertificateUtil.toDateString(1704067200L);
+ assertFalse(result.equals("infinity"), "Positive timestamp should not return 'infinity'");
+ assertTrue(result.contains("2024"), "Timestamp should represent year 2024");
+ }
+
+ /**
+ * Test toDateString with MAX_VALIDITY (max unsigned long treated as signed).
+ */
+ @Test
+ public void testToDateString_maxValidity() {
+ // MAX_VALIDITY is 0xFFFFFFFFFFFFFFFF which is -1 as signed long
+ String result = OpenSshCertificateUtil.toDateString(OpenSshCertificate.MAX_VALIDITY);
+ assertEquals("infinity", result, "MAX_VALIDITY should return 'infinity'");
+ }
+
+ // ==================== Helper methods ====================
+
+ // Dummy byte arrays for required fields in tests
+ private static final byte[] DUMMY_NONCE = new byte[] {1, 2, 3, 4, 5, 6, 7, 8};
+ private static final byte[] DUMMY_PUBLIC_KEY =
+ new byte[] {0, 0, 0, 7, 's', 's', 'h', '-', 'r', 's', 'a', 0, 0, 0, 1, 35, 0, 0, 0, 1, 0};
+ private static final byte[] DUMMY_SIGNATURE_KEY =
+ new byte[] {0, 0, 0, 7, 's', 's', 'h', '-', 'r', 's', 'a', 0, 0, 0, 1, 35, 0, 0, 0, 1, 0};
+ private static final byte[] DUMMY_SIGNATURE =
+ new byte[] {0, 0, 0, 7, 's', 's', 'h', '-', 'r', 's', 'a', 0, 0, 0, 4, 1, 2, 3, 4};
+ private static final byte[] DUMMY_MESSAGE = new byte[] {1, 2, 3, 4, 5, 6, 7, 8, 9, 10};
+
+ /**
+ * Creates a builder pre-configured with valid host certificate defaults.
+ */
+ private OpenSshCertificate.Builder createValidCertificateBuilder() {
+ return new OpenSshCertificate.Builder().keyType("ssh-rsa-cert-v01@openssh.com")
+ .nonce(DUMMY_NONCE).certificatePublicKey(DUMMY_PUBLIC_KEY)
+ .type(OpenSshCertificate.SSH2_CERT_TYPE_HOST).id("test-certificate")
+ .signatureKey(DUMMY_SIGNATURE_KEY).signature(DUMMY_SIGNATURE).message(DUMMY_MESSAGE);
+ }
+
+ // ==================== Tests for hasBeenRevoked ====================
+
+ /**
+ * Test that hasBeenRevoked returns true when key is null (fail-closed).
+ */
+ @Test
+ public void testHasBeenRevoked_nullKey_returnsTrue() throws Exception {
+ JSch jsch = new JSch();
+ KnownHosts knownHosts = new KnownHosts(jsch);
+
+ boolean result = OpenSshCertificateUtil.hasBeenRevoked(knownHosts, null);
+
+ assertTrue(result, "Should return true for null key (fail-closed)");
+ }
+
+ /**
+ * Test that hasBeenRevoked returns false when key is not in revoked list.
+ */
+ @Test
+ public void testHasBeenRevoked_keyNotRevoked_returnsFalse() throws Exception {
+ JSch jsch = new JSch();
+ KnownHosts knownHosts = new KnownHosts(jsch);
+ String base64Key = "AAAAB3NzaC1yc2EAAAADAQABAAABAQ==";
+ byte[] keyBytes = Util.fromBase64(Util.str2byte(base64Key), 0, base64Key.length());
+ HostKey hostKey = new HostKey("example.com", HostKey.SSHRSA, keyBytes);
+
+ boolean result = OpenSshCertificateUtil.hasBeenRevoked(knownHosts, hostKey);
+
+ assertFalse(result, "Should return false when key is not revoked");
+ }
+
+ /**
+ * Test that hasBeenRevoked returns true when key is in revoked list.
+ */
+ @Test
+ public void testHasBeenRevoked_keyIsRevoked_returnsTrue() throws Exception {
+ JSch jsch = new JSch();
+ KnownHosts knownHosts = new KnownHosts(jsch);
+ String base64Key = "AAAAB3NzaC1yc2EAAAADAQABAAABAQ==";
+ byte[] keyBytes = Util.fromBase64(Util.str2byte(base64Key), 0, base64Key.length());
+
+ // Create regular host key
+ HostKey hostKey = new HostKey("example.com", HostKey.SSHRSA, keyBytes);
+
+ // Create revoked entry with same key
+ HostKey revokedKey = new HostKey("@revoked", "example.com", HostKey.SSHRSA, keyBytes, null);
+ knownHosts.add(revokedKey, null);
+
+ boolean result = OpenSshCertificateUtil.hasBeenRevoked(knownHosts, hostKey);
+
+ assertTrue(result, "Should return true when key is revoked");
+ }
+
+ /**
+ * Test that hasBeenRevoked handles revoked entries with null getKey() gracefully.
+ */
+ @Test
+ public void testHasBeenRevoked_revokedEntryWithNullKey_handledGracefully() throws Exception {
+ JSch jsch = new JSch();
+ KnownHosts knownHosts = new KnownHosts(jsch);
+ String base64Key = "AAAAB3NzaC1yc2EAAAADAQABAAABAQ==";
+ byte[] keyBytes = Util.fromBase64(Util.str2byte(base64Key), 0, base64Key.length());
+
+ // Create a host key with valid key
+ HostKey hostKey = new HostKey("example.com", HostKey.SSHRSA, keyBytes);
+
+ // The method should not throw NPE even if revoked entries have null keys
+ // This test verifies the fix for problem 6
+ boolean result = OpenSshCertificateUtil.hasBeenRevoked(knownHosts, hostKey);
+
+ assertFalse(result, "Should handle empty revoked list gracefully");
+ }
+
+ // ==================== Tests for getRawKeyType with centralized constants ====================
+
+ /**
+ * Test that getRawKeyType correctly extracts base key type from certificate types.
+ */
+ @Test
+ public void testGetRawKeyType_certificateType_returnsBaseType() {
+ assertEquals("ssh-rsa", OpenSshCertificateUtil.getRawKeyType("ssh-rsa-cert-v01@openssh.com"));
+ assertEquals("ssh-ed25519",
+ OpenSshCertificateUtil.getRawKeyType("ssh-ed25519-cert-v01@openssh.com"));
+ assertEquals("ecdsa-sha2-nistp256",
+ OpenSshCertificateUtil.getRawKeyType("ecdsa-sha2-nistp256-cert-v01@openssh.com"));
+ }
+
+ /**
+ * Test that getRawKeyType returns original for non-certificate types.
+ */
+ @Test
+ public void testGetRawKeyType_nonCertificateType_returnsOriginal() {
+ assertEquals("ssh-rsa", OpenSshCertificateUtil.getRawKeyType("ssh-rsa"));
+ assertEquals("ssh-ed25519", OpenSshCertificateUtil.getRawKeyType("ssh-ed25519"));
+ }
+
+ /**
+ * Test that getRawKeyType handles null and empty strings.
+ */
+ @Test
+ public void testGetRawKeyType_nullOrEmpty_returnsNull() {
+ assertNull(OpenSshCertificateUtil.getRawKeyType(null));
+ assertNull(OpenSshCertificateUtil.getRawKeyType(""));
+ }
}
diff --git a/src/test/java/com/jcraft/jsch/SessionTest.java b/src/test/java/com/jcraft/jsch/SessionTest.java
index ebc12d984..1764721c2 100644
--- a/src/test/java/com/jcraft/jsch/SessionTest.java
+++ b/src/test/java/com/jcraft/jsch/SessionTest.java
@@ -1,13 +1,18 @@
package com.jcraft.jsch;
+import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
import com.jcraft.jsch.JSchTest.TestLogger;
import java.util.stream.Stream;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.CsvSource;
import org.junit.jupiter.params.provider.MethodSource;
class SessionTest {
@@ -84,4 +89,74 @@ void checkLoggerFunctionality() throws Exception {
JSch.setLogger(orgLogger);
}
}
+
+ // ==================== Tests for CASignatureAlgorithms ====================
+
+ /**
+ * Tests that the default ca_signature_algorithms config matches OpenSSH 8.2+ defaults (excludes
+ * ssh-rsa/SHA-1).
+ */
+ @Test
+ void testDefaultCASignatureAlgorithms() {
+ String defaultCaSigAlgs = JSch.getConfig("ca_signature_algorithms");
+ assertTrue(defaultCaSigAlgs.contains("ssh-ed25519"), "Default should include ssh-ed25519");
+ assertTrue(defaultCaSigAlgs.contains("ecdsa-sha2-nistp256"),
+ "Default should include ecdsa-sha2-nistp256");
+ assertTrue(defaultCaSigAlgs.contains("rsa-sha2-256"), "Default should include rsa-sha2-256");
+ assertTrue(defaultCaSigAlgs.contains("rsa-sha2-512"), "Default should include rsa-sha2-512");
+ // ssh-rsa (SHA-1) should NOT be in the default list (OpenSSH 8.2+ behavior)
+ assertTrue(
+ !defaultCaSigAlgs.contains(",ssh-rsa,") && !defaultCaSigAlgs.endsWith(",ssh-rsa")
+ && !defaultCaSigAlgs.startsWith("ssh-rsa,") && !defaultCaSigAlgs.equals("ssh-rsa"),
+ "Default should NOT include ssh-rsa (SHA-1)");
+ }
+
+ /**
+ * Tests that checkCASignatureAlgorithm passes for algorithms in the allowed list.
+ */
+ @Test
+ void testCheckCASignatureAlgorithm_allowedAlgorithm() throws JSchException {
+ Session session = new Session(jsch, null, null, 0);
+ // ecdsa-sha2-nistp256 is in the default ca_signature_algorithms
+ assertDoesNotThrow(() -> session.checkCASignatureAlgorithm("ecdsa-sha2-nistp256"),
+ "Algorithm in the allowed list should not throw");
+ }
+
+ /**
+ * Tests that checkCASignatureAlgorithm throws for algorithms not in the allowed list.
+ */
+ @Test
+ void testCheckCASignatureAlgorithm_disallowedAlgorithm() throws JSchException {
+ Session session = new Session(jsch, null, null, 0);
+ // ssh-rsa (SHA-1) is NOT in the default ca_signature_algorithms
+ JSchException exception =
+ assertThrows(JSchException.class, () -> session.checkCASignatureAlgorithm("ssh-rsa"),
+ "Algorithm not in the allowed list should throw JSchException");
+ assertTrue(exception.getMessage().contains("not in the allowed ca_signature_algorithms"),
+ "Exception message should indicate algorithm not allowed");
+ }
+
+ /**
+ * Tests that checkCASignatureAlgorithm can be configured to allow ssh-rsa.
+ */
+ @Test
+ void testCheckCASignatureAlgorithm_customConfig() throws JSchException {
+ Session session = new Session(jsch, null, null, 0);
+ // Configure to allow ssh-rsa
+ session.setConfig("ca_signature_algorithms", "ssh-rsa,rsa-sha2-256,rsa-sha2-512");
+ assertDoesNotThrow(() -> session.checkCASignatureAlgorithm("ssh-rsa"),
+ "ssh-rsa should be allowed when explicitly configured");
+ }
+
+ /**
+ * Tests that checkCASignatureAlgorithm allows all algorithms when config is empty.
+ */
+ @Test
+ void testCheckCASignatureAlgorithm_emptyConfig() throws JSchException {
+ Session session = new Session(jsch, null, null, 0);
+ session.setConfig("ca_signature_algorithms", "");
+ // With empty config, no restriction on which algorithms are allowed
+ assertDoesNotThrow(() -> session.checkCASignatureAlgorithm("ecdsa-sha2-nistp256"),
+ "Algorithm should be allowed when config is empty");
+ }
}
diff --git a/src/test/java/com/jcraft/jsch/UserCertAuthIT.java b/src/test/java/com/jcraft/jsch/UserCertAuthIT.java
index c8407fcec..d25c927be 100644
--- a/src/test/java/com/jcraft/jsch/UserCertAuthIT.java
+++ b/src/test/java/com/jcraft/jsch/UserCertAuthIT.java
@@ -32,14 +32,12 @@
* is configured to trust the certificate authority (CA) key that signed the user certificates being
* tested.
*/
-
@Testcontainers
public class UserCertAuthIT {
/**
* Standard SLF4J logger for this test class.
*/
private static final Logger logger = LoggerFactory.getLogger(UserCertAuthIT.class);
-
/**
* Timeout value (in milliseconds) for session and channel connections.
*/
@@ -57,9 +55,6 @@ public class UserCertAuthIT {
*/
private static final TestLogger sshdLogger =
TestLoggerFactory.getTestLogger(UserCertAuthIT.class);
-
-
-
/**
* The Testcontainers instance for the SSHD server.
*
@@ -83,7 +78,6 @@ public class UserCertAuthIT {
.withFileFromClasspath("Dockerfile", "certificates/docker/Dockerfile")).withExposedPorts(22)
.waitingFor(Wait.forLogMessage(".*Server listening on :: port 22.*", 1));
-
/**
* Provides the list of private key parameters used for the parameterized test.
*
@@ -126,6 +120,9 @@ public void opensshCertificateParserTest(String privateKey) throws Exception {
session.setConfig("enable_auth_none", "yes");
session.setConfig("StrictHostKeyChecking", "no");
session.setConfig("PreferredAuthentications", "publickey");
+ // Include ssh-rsa-cert-v01@openssh.com for RSA certificate test (not in defaults per OpenSSH)
+ session.setConfig("PubkeyAcceptedAlgorithms",
+ "ssh-ed25519-cert-v01@openssh.com,ecdsa-sha2-nistp256-cert-v01@openssh.com,ecdsa-sha2-nistp384-cert-v01@openssh.com,ecdsa-sha2-nistp521-cert-v01@openssh.com,ssh-rsa-cert-v01@openssh.com,ssh-ed25519,ecdsa-sha2-nistp256,ecdsa-sha2-nistp384,ecdsa-sha2-nistp521,rsa-sha2-512,rsa-sha2-256");
session.setConfig("server_host_key",
"ssh-ed25519,ecdsa-sha2-nistp256,ecdsa-sha2-nistp384,ecdsa-sha2-nistp521,rsa-sha2-512,rsa-sha2-256");
doSftp(session);