From 053b94c23b170c8058a9039f7a210e98aee69c1f Mon Sep 17 00:00:00 2001 From: Luigi De Masi Date: Wed, 17 Sep 2025 18:15:18 +0200 Subject: [PATCH 1/6] Add support for OpenSSH certificates, resolve #31 --- src/main/java/com/jcraft/jsch/Buffer.java | 14 +- src/main/java/com/jcraft/jsch/JSch.java | 41 ++- .../com/jcraft/jsch/OpenSshCertificate.java | 301 ++++++++++++++++++ .../OpenSshCertificateAwareIdentityFile.java | 236 ++++++++++++++ .../jcraft/jsch/OpenSshCertificateBuffer.java | 158 +++++++++ .../jcraft/jsch/OpenSshCertificateUtil.java | 216 +++++++++++++ .../jcraft/jsch/OpensshCertificateParser.java | 181 +++++++++++ .../com/jcraft/jsch/UserAuthPublicKey.java | 7 +- .../java/com/jcraft/jsch/UserCertAuthIT.java | 112 +++++++ .../resources/certificates/ca/ca_jsch_key | 7 + .../resources/certificates/ca/ca_jsch_key.pub | 1 + .../resources/certificates/docker/Dockerfile | 17 + .../certificates/docker/ssh_host_rsa_key | 49 +++ .../docker/ssh_host_rsa_key-cert.pub | 1 + .../certificates/docker/ssh_host_rsa_key.pub | 1 + .../resources/certificates/docker/sshd_config | 12 + .../resources/certificates/dss/root_dsa_key | 12 + .../certificates/dss/root_dsa_key-cert.pub | 1 + .../certificates/dss/root_dsa_key.pub | 1 + .../ecdsa_p256/root_ecdsa_sha2_nistp256_key | 9 + .../root_ecdsa_sha2_nistp256_key-cert.pub | 1 + .../root_ecdsa_sha2_nistp256_key.pub | 1 + .../ecdsa_p384/root_ecdsa-sha2-nistp384_key | 10 + .../root_ecdsa-sha2-nistp384_key-cert.pub | 1 + .../root_ecdsa-sha2_nistp384_key.pub | 1 + .../ecdsa_p521/root_ecdsa_sha2_nistp521_key | 12 + .../root_ecdsa_sha2_nistp521_key-cert.pub | 1 + .../root_ecdsa_sha2_nistp521_key.pub | 1 + .../certificates/ed25519/root_ed25519_key | 7 + .../ed25519/root_ed25519_key-cert.pub | 1 + .../certificates/ed25519/root_ed25519_key.pub | 1 + .../resources/certificates/rsa/root_rsa_key | 49 +++ .../certificates/rsa/root_rsa_key-cert.pub | 1 + .../certificates/rsa/root_rsa_key.pub | 1 + 34 files changed, 1455 insertions(+), 10 deletions(-) create mode 100644 src/main/java/com/jcraft/jsch/OpenSshCertificate.java create mode 100644 src/main/java/com/jcraft/jsch/OpenSshCertificateAwareIdentityFile.java create mode 100644 src/main/java/com/jcraft/jsch/OpenSshCertificateBuffer.java create mode 100644 src/main/java/com/jcraft/jsch/OpenSshCertificateUtil.java create mode 100644 src/main/java/com/jcraft/jsch/OpensshCertificateParser.java create mode 100644 src/test/java/com/jcraft/jsch/UserCertAuthIT.java create mode 100644 src/test/resources/certificates/ca/ca_jsch_key create mode 100644 src/test/resources/certificates/ca/ca_jsch_key.pub create mode 100644 src/test/resources/certificates/docker/Dockerfile create mode 100644 src/test/resources/certificates/docker/ssh_host_rsa_key create mode 100644 src/test/resources/certificates/docker/ssh_host_rsa_key-cert.pub create mode 100644 src/test/resources/certificates/docker/ssh_host_rsa_key.pub create mode 100644 src/test/resources/certificates/docker/sshd_config create mode 100644 src/test/resources/certificates/dss/root_dsa_key create mode 100644 src/test/resources/certificates/dss/root_dsa_key-cert.pub create mode 100644 src/test/resources/certificates/dss/root_dsa_key.pub create mode 100644 src/test/resources/certificates/ecdsa_p256/root_ecdsa_sha2_nistp256_key create mode 100644 src/test/resources/certificates/ecdsa_p256/root_ecdsa_sha2_nistp256_key-cert.pub create mode 100644 src/test/resources/certificates/ecdsa_p256/root_ecdsa_sha2_nistp256_key.pub create mode 100644 src/test/resources/certificates/ecdsa_p384/root_ecdsa-sha2-nistp384_key create mode 100644 src/test/resources/certificates/ecdsa_p384/root_ecdsa-sha2-nistp384_key-cert.pub create mode 100644 src/test/resources/certificates/ecdsa_p384/root_ecdsa-sha2_nistp384_key.pub create mode 100644 src/test/resources/certificates/ecdsa_p521/root_ecdsa_sha2_nistp521_key create mode 100644 src/test/resources/certificates/ecdsa_p521/root_ecdsa_sha2_nistp521_key-cert.pub create mode 100644 src/test/resources/certificates/ecdsa_p521/root_ecdsa_sha2_nistp521_key.pub create mode 100644 src/test/resources/certificates/ed25519/root_ed25519_key create mode 100644 src/test/resources/certificates/ed25519/root_ed25519_key-cert.pub create mode 100644 src/test/resources/certificates/ed25519/root_ed25519_key.pub create mode 100644 src/test/resources/certificates/rsa/root_rsa_key create mode 100644 src/test/resources/certificates/rsa/root_rsa_key-cert.pub create mode 100644 src/test/resources/certificates/rsa/root_rsa_key.pub diff --git a/src/main/java/com/jcraft/jsch/Buffer.java b/src/main/java/com/jcraft/jsch/Buffer.java index e57fef608..f2b4f1fb0 100644 --- a/src/main/java/com/jcraft/jsch/Buffer.java +++ b/src/main/java/com/jcraft/jsch/Buffer.java @@ -28,9 +28,17 @@ public class Buffer { final byte[] tmp = new byte[4]; - byte[] buffer; - int index; - int s; + protected byte[] buffer; + + // write position + // Tracks the current writing position in the buffer and points to where the next + // write operation will occur and increments as data is written + protected int index; + + // read position - Tracks the current reading position in the buffer and points to where the next + // read operation will + // occur and increments as data is read + protected int s; public Buffer(int size) { buffer = new byte[size]; diff --git a/src/main/java/com/jcraft/jsch/JSch.java b/src/main/java/com/jcraft/jsch/JSch.java index 729f2ba67..a4638e0b7 100644 --- a/src/main/java/com/jcraft/jsch/JSch.java +++ b/src/main/java/com/jcraft/jsch/JSch.java @@ -26,7 +26,10 @@ package com.jcraft.jsch; + +import java.io.IOException; import java.io.InputStream; +import java.nio.charset.StandardCharsets; import java.util.Collections; import java.util.Enumeration; import java.util.HashMap; @@ -34,6 +37,9 @@ import java.util.Map; import java.util.Vector; +import static com.jcraft.jsch.OpenSshCertificateAwareIdentityFile.isOpenSshCertificate; +import static java.nio.charset.StandardCharsets.*; + public class JSch { /** The version number. */ public static final String VERSION = Version.getVersion(); @@ -239,7 +245,9 @@ 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,ecdsa-sha2-nistp256,ecdsa-sha2-nistp384,ecdsa-sha2-nistp521,rsa-sha2-512,rsa-sha2-256")); + "ssh-ed25519,ecdsa-sha2-nistp256,ecdsa-sha2-nistp384,ecdsa-sha2-nistp521,rsa-sha2-512,rsa-sha2-256," + + "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")); config.put("enable_pubkey_auth_query", Util.getSystemProperty("jsch.enable_pubkey_auth_query", "yes")); config.put("try_additional_pubkey_algorithms", @@ -320,8 +328,8 @@ public JSch() {} * "user.name" will be referred. * * @param host hostname - * @throws JSchException if username or host are invalid. * @return the instance of Session class. + * @throws JSchException if username or host are invalid. * @see #getSession(String username, String host, int port) * @see com.jcraft.jsch.Session * @see com.jcraft.jsch.ConfigRepository @@ -337,8 +345,8 @@ public Session getSession(String host) throws JSchException { * * @param username user name * @param host hostname - * @throws JSchException if username or host are invalid. * @return the instance of Session class. + * @throws JSchException if username or host are invalid. * @see #getSession(String username, String host, int port) * @see com.jcraft.jsch.Session */ @@ -354,8 +362,8 @@ public Session getSession(String username, String host) throws JSchException { * @param username user name * @param host hostname * @param port port number - * @throws JSchException if username or host are invalid. * @return the instance of Session class. + * @throws JSchException if username or host are invalid. * @see #getSession(String username, String host, int port) * @see com.jcraft.jsch.Session */ @@ -492,7 +500,21 @@ 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 { - Identity identity = IdentityFile.newInstance(prvkey, pubkey, instLogger); + String pubkeyFileContent; + Identity identity; + + try { + pubkeyFileContent = new String(Util.fromFile(pubkey), UTF_8); + } catch (IOException e) { + throw new JSchException(e.toString(), e); + } + + if (isOpenSshCertificate(pubkeyFileContent)) { + identity = OpenSshCertificateAwareIdentityFile.newInstance(prvkey, pubkey, instLogger); + } else { + identity = IdentityFile.newInstance(prvkey, pubkey, instLogger); + } + addIdentity(identity, passphrase); } @@ -507,7 +529,14 @@ public void addIdentity(String prvkey, String pubkey, byte[] passphrase) throws */ public void addIdentity(String name, byte[] prvkey, byte[] pubkey, byte[] passphrase) throws JSchException { - Identity identity = IdentityFile.newInstance(name, prvkey, pubkey, instLogger); + String pubkeyFileContent = new String(pubkey, UTF_8); + Identity identity; + + if (isOpenSshCertificate(pubkeyFileContent)) { + identity = OpenSshCertificateAwareIdentityFile.newInstance(name, prvkey, pubkey, instLogger); + } else { + identity = IdentityFile.newInstance(name, prvkey, pubkey, instLogger); + } addIdentity(identity, passphrase); } diff --git a/src/main/java/com/jcraft/jsch/OpenSshCertificate.java b/src/main/java/com/jcraft/jsch/OpenSshCertificate.java new file mode 100644 index 000000000..7c00ad479 --- /dev/null +++ b/src/main/java/com/jcraft/jsch/OpenSshCertificate.java @@ -0,0 +1,301 @@ +package com.jcraft.jsch; + +import java.util.Collection; +import java.util.Map; + +/** + * Represents an OpenSSH certificate containing all the fields defined in the OpenSSH certificate + * format. + * + *

+ * 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). + *

+ * + *

+ * This class supports both user certificates (for authenticating users to hosts) and host + * certificates (for authenticating hosts to users). + *

+ * + * @see OpenSSH Certificate + * Protocol + */ +public class OpenSshCertificate { + + /** + * Certificate type constant for user certificates + */ + public static final int SSH2_CERT_TYPE_USER = 1; + + /** + * Certificate type constant for user certificates + */ + public static final int SSH2_CERT_TYPE_HOST = 2; + + /** + * Minimum validity period (epoch start) + */ + public static final long MIN_VALIDITY = 0L; + + /** + * Maximum validity period (maximum unsigned 64-bit value) + */ + public static final long MAX_VALIDITY = 0xffff_ffff_ffff_ffffL; + + /** + * The certificate key type (e.g., "ssh-rsa-cert-v01@openssh.com") + */ + private final String keyType; + + /** + * Random nonce to make certificates unique + */ + private final byte[] nonce; + + /** + * The certificate's public key in SSH wire format + */ + private final byte[] certificatePublicKey; + + /** + * Certificate serial number + */ + private final long serial; + + /** + * Certificate type (user or host) + */ + private final int type; + + /** + * Certificate identifier string + */ + private final String id; + + /** + * Collection of principal names this certificate is valid for + */ + private final Collection principals; + + // match ssh-keygen behavior where the default is the epoch + private final long validAfter; + + // match ssh-keygen behavior where the default would be forever + private final long validBefore; + + /** + * Critical options that must be recognized by the SSH implementation + */ + private final Map criticalOptions; + + /** + * Extensions that provide additional functionality + */ + private final Map extensions; + + /** + * Reserved field for future use + */ + private final String reserved; + + /** + * The CA's public key that signed this certificate + */ + private final byte[] signatureKey; + + /** + * The cryptographic signature of the certificate + */ + private final byte[] signature; + + /** + * Private constructor to be used exclusively by the Builder. + */ + private OpenSshCertificate(Builder builder) { + this.keyType = builder.keyType; + this.nonce = builder.nonce; + this.certificatePublicKey = builder.certificatePublicKey; + this.serial = builder.serial; + this.type = builder.type; + this.id = builder.id; + this.principals = builder.principals; + this.validAfter = builder.validAfter; + this.validBefore = builder.validBefore; + this.criticalOptions = builder.criticalOptions; + this.extensions = builder.extensions; + this.reserved = builder.reserved; + this.signatureKey = builder.signatureKey; + this.signature = builder.signature; + } + + public String getKeyType() { + return keyType; + } + + public byte[] getNonce() { + return nonce; + } + + public byte[] getCertificatePublicKey() { + return certificatePublicKey; + } + + public long getSerial() { + return serial; + } + + public int getType() { + return type; + } + + public String getId() { + return id; + } + + public Collection getPrincipals() { + return principals; + } + + public long getValidAfter() { + return validAfter; + } + + public long getValidBefore() { + return validBefore; + } + + public Map getCriticalOptions() { + return criticalOptions; + } + + public Map getExtensions() { + return extensions; + } + + public String getReserved() { + return reserved; + } + + public byte[] getSignatureKey() { + return signatureKey; + } + + public byte[] getSignature() { + return signature; + } + + public boolean isUserCertificate() { + return SSH2_CERT_TYPE_USER == type; + } + + public boolean isHostCertificate() { + return SSH2_CERT_TYPE_HOST == type; + } + + public boolean isValidNow() { + return OpenSshCertificateUtil.isValidNow(this); + } + + /** + * A static inner builder class for creating immutable OpenSshCertificate instances. + */ + public static class Builder { + private String keyType; + private byte[] nonce; + private byte[] certificatePublicKey; + private long serial; + private int type; + private String id; + private Collection principals; + private long validAfter = MIN_VALIDITY; + private long validBefore = MAX_VALIDITY; + private Map criticalOptions; + private Map extensions; + private String reserved; + private byte[] signatureKey; + private byte[] signature; + + public Builder() {} + + public Builder keyType(String keyType) { + this.keyType = keyType; + return this; + } + + public Builder nonce(byte[] nonce) { + this.nonce = nonce; + return this; + } + + public Builder certificatePublicKey(byte[] pk) { + this.certificatePublicKey = pk; + return this; + } + + public Builder serial(long serial) { + this.serial = serial; + return this; + } + + public Builder type(int type) { + this.type = type; + return this; + } + + public Builder id(String id) { + this.id = id; + return this; + } + + public Builder principals(Collection principals) { + this.principals = principals; + return this; + } + + public Builder validAfter(long validAfter) { + this.validAfter = validAfter; + return this; + } + + public Builder validBefore(long validBefore) { + this.validBefore = validBefore; + return this; + } + + public Builder criticalOptions(Map opts) { + this.criticalOptions = opts; + return this; + } + + public Builder extensions(Map exts) { + this.extensions = exts; + return this; + } + + public Builder reserved(String reserved) { + this.reserved = reserved; + return this; + } + + public Builder signatureKey(byte[] sigKey) { + this.signatureKey = sigKey; + return this; + } + + public Builder signature(byte[] signature) { + this.signature = signature; + return this; + } + + /** + * Constructs and returns an immutable OpenSshCertificate instance. + * + * @return A new, immutable OpenSshCertificate object. + */ + public 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 new file mode 100644 index 000000000..bc8d9b788 --- /dev/null +++ b/src/main/java/com/jcraft/jsch/OpenSshCertificateAwareIdentityFile.java @@ -0,0 +1,236 @@ +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.trimToEmptyIfNull; +import static java.nio.charset.StandardCharsets.UTF_8; + +/** + * An {@link Identity} implementation that supports OpenSSH certificates. + * + *

+ * This class handles SSH identity files that contain OpenSSH certificates, which combine a public + * key with additional metadata and restrictions signed by a certificate authority. It supports all + * standard OpenSSH certificate types including RSA, DSA, ECDSA, and Ed25519. + *

+ * + *

+ * The class can load certificates from file pairs (private key file + certificate file) and + * provides all the functionality needed for SSH authentication using certificates. + *

+ */ +public 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_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 = + "ecdsa-sha2-nistp256-cert-v01@openssh.com"; + public 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_NISTP521_CERT_V01_AT_OPENSSH_DOT_COM = + "ecdsa-sha2-nistp521-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 RSA_SHA2_256_CERT_V01_AT_OPENSSH_DOT_COM = + "rsa-sha2-256-cert-v01@openssh.com"; + public static final String RSA_SHA2_512_CERT_V01_AT_OPENSSH_DOT_COM = + "rsa-sha2-512-cert-v01@openssh.com"; + + + /** + * 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 RSA_SHA2_256_CERT_V01_AT_OPENSSH_DOT_COM: + case RSA_SHA2_512_CERT_V01_AT_OPENSSH_DOT_COM: + return true; + default: + return false; + } + } + + /** parsed certificate. **/ + private final OpenSshCertificate certificate; + + /** the key type declared in the first part of the file **/ + private final String keyType; + + /** The entire certificate as raw bytes */ + private final byte[] publicKeyBlob; + + /** The key pair containing the private key */ + private final KeyPair kpair; + + /** The identity name/path */ + private final String identity; + + /** Optional comment associated with the identity */ + private final String comment; + + + /** + * Creates a new certificate-aware identity from file paths. + * + * @param prvfile path to the private key file + * @param pubfile path to the certificate file + * @param instLogger logger instance for debugging + * @return a new Identity instance + * @throws JSchException if the files cannot be loaded or parsed + */ + static Identity newInstance(String prvfile, String pubfile, JSch.InstanceLogger instLogger) + throws JSchException { + byte[] prvkey; + byte[] pubkey; + + try { + prvkey = Util.fromFile(prvfile); + pubkey = Util.fromFile(pubfile); + } catch (IOException e) { + throw new JSchException(e.toString(), e); + } + return newInstance(prvfile, prvkey, pubkey, instLogger); + } + + /** + * Creates a new certificate-aware identity from byte arrays. + * + * @param name the identity name + * @param prvkey the private key bytes + * @param pubkey the certificate bytes + * @param instLogger logger instance for debugging + * @return a new Identity instance + * @throws JSchException if the certificate cannot be parsed + */ + static Identity newInstance(String name, byte[] prvkey, byte[] pubkey, + JSch.InstanceLogger instLogger) throws JSchException { + String certString = new String(pubkey, UTF_8); + OpenSshCertificate cert; + byte[] certPublicKey; + KeyPair kpair; + String keyType; + String comment; + String base64KeyData; + + try { + cert = new OpensshCertificateParser(instLogger, certString).parse(); + certPublicKey = cert.getCertificatePublicKey(); + kpair = KeyPair.load(instLogger, prvkey, certPublicKey); + keyType = extractKeyType(certString); + base64KeyData = extractKeyData(certString); + comment = extractComment(certString); + + + } catch (IOException | NoSuchAlgorithmException e) { + throw new JSchException(e.toString(), e); + } + return new OpenSshCertificateAwareIdentityFile(name, keyType, base64KeyData, cert, kpair, + comment); + } + + /** + * Private constructor for creating certificate-aware identity instances. + * + * @param name the identity name + * @param keyType the key type declared in the certificate file + * @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, String base64KeyData, + OpenSshCertificate certificate, KeyPair kpair, String comment) { + this.identity = name; + this.certificate = certificate; + this.kpair = kpair; + this.comment = comment; + this.keyType = keyType; + this.publicKeyBlob = Base64.getDecoder().decode(base64KeyData); + } + + @Override + public boolean setPassphrase(byte[] passphrase) { + return kpair.decrypt(passphrase); + } + + @Override + public byte[] getPublicKeyBlob() { + return publicKeyBlob; + } + + @Override + public byte[] getSignature(byte[] data) { + return kpair.getSignature(data); + } + + @Override + public byte[] getSignature(byte[] data, String alg) { + String rawKeyType = getRawKeyType(keyType); + return kpair.getSignature(data, rawKeyType); + } + + @Override + public String getAlgName() { + return certificate.getKeyType(); + } + + @Override + public String getName() { + return identity; + } + + @Override + public boolean isEncrypted() { + return kpair.isEncrypted(); + } + + @Override + public void clear() { + kpair.dispose(); + } + + public String getKeyType() { + return keyType; + } + + public KeyPair getKpair() { + return kpair; + } + + + public String getIdentity() { + return identity; + } + + public String getComment() { + return comment; + } +} diff --git a/src/main/java/com/jcraft/jsch/OpenSshCertificateBuffer.java b/src/main/java/com/jcraft/jsch/OpenSshCertificateBuffer.java new file mode 100644 index 000000000..816bc5a94 --- /dev/null +++ b/src/main/java/com/jcraft/jsch/OpenSshCertificateBuffer.java @@ -0,0 +1,158 @@ +package com.jcraft.jsch; + +import java.nio.charset.Charset; +import java.util.Collection; +import java.util.LinkedHashMap; +import java.util.LinkedList; +import java.util.Map; + +import static com.jcraft.jsch.OpenSshCertificateUtil.*; +import static java.nio.charset.StandardCharsets.UTF_8; + +/** + * A specialized buffer for parsing OpenSSH certificate data. + * + *

+ * This class extends the base {@link Buffer} class to provide additional methods specific to + * parsing OpenSSH certificate format data structures, including string collections, key-value maps, + * and certificate-specific data types. + *

+ * + *

+ * The buffer follows the SSH wire format protocol for data serialization, where strings and byte + * arrays are prefixed with their length as a 32-bit integer. + *

+ */ +public class OpenSshCertificateBuffer extends Buffer { + + private static final byte[] EMPTY_BYTE_ARRAY = {}; + + + /** + * Creates a new OpenSSH certificate buffer from decoded certificate bytes. + * + * @param certificateByteDecoded the decoded certificate data + */ + public OpenSshCertificateBuffer(byte[] certificateByteDecoded) { + super(certificateByteDecoded); + s = 0; + index = certificateByteDecoded.length; + + } + + /** + * Reads a length-prefixed byte array from the buffer. + * + * @return the byte array data + */ + public 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. + * + *

+ * This method reads all remaining data in the buffer and parses it as a sequence of + * length-prefixed UTF-8 strings. + *

+ * + * @return collection of strings + */ + public Collection getStrings() { + Collection list = new LinkedList<>(); + while (getLength() > 0) { + String s = getString(UTF_8); + list.add(s); + } + return list; + } + + /** + * Reads critical options from the buffer. + * + *

+ * Critical options are stored as key-value pairs in SSH wire format. + *

+ * + * @return map of critical option names to values + */ + public Map getCriticalOptions() { + return getKeyValueData(); + } + + /** + * Reads extensions from the buffer. + * + *

+ * Extensions are stored as key-value pairs in SSH wire format. + *

+ * + * @return map of extension names to values + */ + public Map getExtensions() { + return getKeyValueData(); + } + + /** + * Reads key-value pair data from the buffer. + * + *

+ * This method handles the SSH wire format for storing maps, where the entire map is first stored + * as a length-prefixed blob, followed by alternating keys and values, each also length-prefixed. + *

+ * + * @return map of keys to values + */ + private Map getKeyValueData() { + Map map = new LinkedHashMap<>(); + + if (getLength() > 0) { + OpenSshCertificateBuffer keyValueDataBuffer = new OpenSshCertificateBuffer(getString()); + while (keyValueDataBuffer.getLength() > 0) { + String key = keyValueDataBuffer.getString(UTF_8); + String value = keyValueDataBuffer.getString(UTF_8); + map.put(key, value); + } + } + return map; + } + + /** + * Writes a UTF-8 encoded string to the buffer with length prefix. + * + * @param string the strin + */ + public void putString(String string) { + if (isEmpty(string)) { + putByte(EMPTY_BYTE_ARRAY); + } else { + byte[] stringBytes = string.getBytes(UTF_8); + putInt(stringBytes.length); + putByte(stringBytes); + } + } + + + public int getReadPosition() { + return s; + } + + public int getWritePosition() { + return index; + } +} diff --git a/src/main/java/com/jcraft/jsch/OpenSshCertificateUtil.java b/src/main/java/com/jcraft/jsch/OpenSshCertificateUtil.java new file mode 100644 index 000000000..cc0c06ac0 --- /dev/null +++ b/src/main/java/com/jcraft/jsch/OpenSshCertificateUtil.java @@ -0,0 +1,216 @@ +package com.jcraft.jsch; + +import java.util.Date; +import java.util.concurrent.TimeUnit; + +import static java.nio.charset.StandardCharsets.UTF_8; +import static java.util.concurrent.TimeUnit.MILLISECONDS; + +public class OpenSshCertificateUtil { + + + /** + * Converts a byte array to a UTF-8 string, replaces tab characters with spaces, and trims + * whitespace. + * + * @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) + */ + static String tabToSpaceAndTrim(byte[] s) { + String str = new String(s, UTF_8); + return tabToSpaceAndTrim(str); + } + + /** + * Replaces all tab characters in the input string with space characters and trims leading and + * trailing whitespace. + * + * @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) + */ + static String tabToSpaceAndTrim(String s) { + if (s != null) { + s = s.replace('\t', ' '); + } + + return trimToEmptyIfNull(s); + } + + /** + * Trims leading and trailing whitespace from the input string. Returns an empty string if the + * input is null. + * + * @param s the string to trim, may be null + * @return the trimmed string, or an empty string if the input is null + */ + static String trimToEmptyIfNull(String s) { + if (s == null) { + return ""; + } else { + return s.trim(); + } + } + + /** + * Checks if a CharSequence 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 + */ + static boolean isEmpty(CharSequence cs) { + return cs == null || cs.length() == 0; + } + + /** + * 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. + * + * @param certificateFileContent The content of the certificate file as a single string. + * @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 { + 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. + * + * @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. + */ + public static String extractComment(String certificateFileContent) + throws IllegalArgumentException { + 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. + * + * @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. + */ + public static String extractKeyData(String 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}. + * + * @param certificate The string 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. + */ + public static String extractSpaceDelimitedString(String certificate, int index) { + if (certificate == null || certificate.trim().isEmpty()) { + return null; + } + String[] fields = certificate.split("\\s+"); + + if (index >= 0 && index < fields.length) { + return fields[index]; + } else { + return null; + } + } + + + /** + * 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); + } + + + /** + * Determines whether the given {@link OpenSshCertificate} is valid at the current local system + * time. + * + * @param cert to check + * @return {@code true} if the certificate is valid according to its timestamps, {@code false} + * otherwise + */ + static boolean isValidNow(OpenSshCertificate cert) { + long now = MILLISECONDS.toSeconds(System.currentTimeMillis()); + return Long.compareUnsigned(cert.getValidAfter(), now) <= 0 + && Long.compareUnsigned(now, cert.getValidBefore()) < 0; + } + + /** + * 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. + * + * @param timestamp The Unix timestamp in seconds. + * @return A string representing the date, or "infinity" if the timestamp is negative. + */ + static String toDateString(long timestamp) { + if (timestamp < 0) { + return "infinity"; + } + Date date = new Date(TimeUnit.SECONDS.toMillis(timestamp)); + return date.toString(); + } + + /** + * Extracts the raw key type from a given key type string. + * + * This method assumes the key type string is in a specific format, such as + * "ssh-rsa-etc-bla-bla@something-cert-something". It splits the string at the "@" character and + * then extracts the substring up to the "-cert" part. It is null-safe and handles empty strings + * gracefully. + * + * @param keyType The full key type string. + * @return The raw key type (e.g., "ssh-rsa"), or {@code null} if the input is null or empty. + */ + static String getRawKeyType(String keyType) { + if (isEmpty(keyType)) { + return null; + } + + int atIndex = keyType.indexOf("@"); + if (atIndex == -1) { + return null; + } + String prefix = keyType.substring(0, atIndex); + + int certIndex = prefix.indexOf("-cert"); + if (certIndex == -1) { + return null; + } + String subPrefix = prefix.substring(0, certIndex); + + + if (isEmpty(prefix) || isEmpty(subPrefix)) { + return null; + } + + return subPrefix; + } + +} diff --git a/src/main/java/com/jcraft/jsch/OpensshCertificateParser.java b/src/main/java/com/jcraft/jsch/OpensshCertificateParser.java new file mode 100644 index 000000000..c89a0b609 --- /dev/null +++ b/src/main/java/com/jcraft/jsch/OpensshCertificateParser.java @@ -0,0 +1,181 @@ +package com.jcraft.jsch; + +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_V01_AT_OPENSSH_DOT_COM; +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_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_V01_AT_OPENSSH_DOT_COM; +import static com.jcraft.jsch.OpenSshCertificateAwareIdentityFile.SSH_ED25519_CERT_V01_AT_OPENSSH_DOT_COM; +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; + +/** + * Parser for OpenSSH certificate format. + * + *

+ * This class is responsible for parsing OpenSSH certificates from their string representation + * (typically found in .pub files) into structured {@link OpenSshCertificate} objects. It handles + * the base64 decoding and binary parsing of all certificate fields according to the OpenSSH + * certificate specification. + *

+ * + *

+ * The parser supports all standard OpenSSH certificate types including RSA, DSA, ECDSA, and Ed25519 + * certificates. + *

+ * + * @see OpenSshCertificate + * @see OpenSSH Certificate + * Protocol + */ +public 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); + } + + /** + * Parses the certificate data and returns a complete {@link OpenSshCertificate} object. + * + *

+ * This method reads all fields from the certificate in the order specified by the OpenSSH + * certificate format: + *

+ *
    + *
  1. Key type
  2. + *
  3. Nonce
  4. + *
  5. Certificate public key
  6. + *
  7. Serial number
  8. + *
  9. Certificate type
  10. + *
  11. Key ID
  12. + *
  13. Valid principals
  14. + *
  15. Valid after timestamp
  16. + *
  17. Valid before timestamp
  18. + *
  19. Critical options
  20. + *
  21. Extensions
  22. + *
  23. Reserved field
  24. + *
  25. Signature key
  26. + *
  27. Signature
  28. + *
+ * + * @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 { + + 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); + openSshCertificateBuilder.certificatePublicKey(publicKey.getPublicKeyBlob()) + .serial(buffer.getLong()).type(buffer.getInt()).id(buffer.getString(UTF_8)); + + // Principals + byte[] principalsBlob = buffer.getBytes(); + OpenSshCertificateBuffer principalsBuffer = new OpenSshCertificateBuffer(principalsBlob); + Collection principals = principalsBuffer.getStrings(); + 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()); + + + OpenSshCertificate certificate = openSshCertificateBuilder.build(); + + 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()); + } + + if (!certificate.isValidNow()) { + instLogger.getLogger().log(Logger.WARN, + "certificate is not valid. Valid after: " + toDateString(certificate.getValidAfter()) + + " - Valid before: " + toDateString(certificate.getValidBefore())); + } + + + return certificate; + } + + + private KeyPair parsePublicKey(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: + 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: + 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: + byte[] name = buffer.getString(); + int len = buffer.getInt(); + int x04 = buffer.getByte(); + 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 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); + + default: + throw new JSchException("Unsupported Algorithm for Certificate public key: " + keyType); + } + } +} diff --git a/src/main/java/com/jcraft/jsch/UserAuthPublicKey.java b/src/main/java/com/jcraft/jsch/UserAuthPublicKey.java index 0b5f01b30..1f9721be2 100644 --- a/src/main/java/com/jcraft/jsch/UserAuthPublicKey.java +++ b/src/main/java/com/jcraft/jsch/UserAuthPublicKey.java @@ -33,6 +33,8 @@ import java.util.List; import java.util.Vector; +import static com.jcraft.jsch.OpenSshCertificateUtil.getRawKeyType; + class UserAuthPublicKey extends UserAuth { @Override @@ -74,7 +76,10 @@ public boolean start(Session session) throws Exception { for (String pkmethod : pkmethods) { boolean add = false; for (String server_sig_alg : server_sig_algs) { - if (pkmethod.equals(server_sig_alg)) { + // This cover the case of the public key is in Openssh certificate format. + String pkRawMethod = getRawKeyType(pkmethod); + if (pkmethod.equals(server_sig_alg) + || (pkRawMethod != null && pkRawMethod.equals(server_sig_alg))) { add = true; break; } diff --git a/src/test/java/com/jcraft/jsch/UserCertAuthIT.java b/src/test/java/com/jcraft/jsch/UserCertAuthIT.java new file mode 100644 index 000000000..04a6841e4 --- /dev/null +++ b/src/test/java/com/jcraft/jsch/UserCertAuthIT.java @@ -0,0 +1,112 @@ +package com.jcraft.jsch; + +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.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.images.builder.ImageFromDockerfile; +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 java.nio.charset.StandardCharsets.UTF_8; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertTrue; + +@Testcontainers +public class UserCertAuthIT { + private static final Logger logger = LoggerFactory.getLogger(UserCertAuthIT.class); + + private static final int timeout = 2000; + private static final DigestUtils sha256sum = new DigestUtils(DigestUtils.getSha256Digest()); + private static final TestLogger jschLogger = TestLoggerFactory.getTestLogger(JSch.class); + private static final TestLogger sshdLogger = + TestLoggerFactory.getTestLogger(UserCertAuthIT.class); + + + @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-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); + + + public static Iterable privateKeyParams() { + return Arrays.asList( + // disable dss because dsa algotrithm 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"); + } + + + @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")); + JSch ssh = new JSch(); + ssh.addIdentity(getResourceFile("certificates/" + privateKey), + getResourceFile("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"); + doSftp(session); + } + + private HostKey readHostKey(String fileName) throws Exception { + List lines = Files.readAllLines(Paths.get(fileName), 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])); + } + + + private void doSftp(Session session) throws Exception { + assertDoesNotThrow(() -> { + 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; + } + }); + } + + 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(""); + } + + private String getResourceFile(String fileName) { + return ResourceUtil.getResourceFile(getClass(), fileName); + } +} diff --git a/src/test/resources/certificates/ca/ca_jsch_key b/src/test/resources/certificates/ca/ca_jsch_key new file mode 100644 index 000000000..ff7b96075 --- /dev/null +++ b/src/test/resources/certificates/ca/ca_jsch_key @@ -0,0 +1,7 @@ +-----BEGIN OPENSSH PRIVATE KEY----- +b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW +QyNTUxOQAAACCk+kZHaz0pZsY+GYszU0oEitHruzVPS4Eu4SUgXD7BcAAAAJi+25zGvtuc +xgAAAAtzc2gtZWQyNTUxOQAAACCk+kZHaz0pZsY+GYszU0oEitHruzVPS4Eu4SUgXD7BcA +AAAECbHrZvBtnSJkxCPQWTjFd7CiXOeZZEGYJKxXaulGqWEaT6RkdrPSlmxj4ZizNTSgSK +0eu7NU9LgS7hJSBcPsFwAAAAEWxkZW1hc2lAYnVtYmxlYmVlAQIDBA== +-----END OPENSSH PRIVATE KEY----- diff --git a/src/test/resources/certificates/ca/ca_jsch_key.pub b/src/test/resources/certificates/ca/ca_jsch_key.pub new file mode 100644 index 000000000..d19506242 --- /dev/null +++ b/src/test/resources/certificates/ca/ca_jsch_key.pub @@ -0,0 +1 @@ +ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIKT6RkdrPSlmxj4ZizNTSgSK0eu7NU9LgS7hJSBcPsFw ca@jsch diff --git a/src/test/resources/certificates/docker/Dockerfile b/src/test/resources/certificates/docker/Dockerfile new file mode 100644 index 000000000..24d9851e0 --- /dev/null +++ b/src/test/resources/certificates/docker/Dockerfile @@ -0,0 +1,17 @@ +FROM alpine:3.13 +RUN apk update && \ + apk upgrade && \ + apk add openssh openssh-server bash && \ + rm /var/cache/apk/* && \ + 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-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 + +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 diff --git a/src/test/resources/certificates/docker/ssh_host_rsa_key b/src/test/resources/certificates/docker/ssh_host_rsa_key new file mode 100644 index 000000000..e6ccd0659 --- /dev/null +++ b/src/test/resources/certificates/docker/ssh_host_rsa_key @@ -0,0 +1,49 @@ +-----BEGIN OPENSSH PRIVATE KEY----- +b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAACFwAAAAdzc2gtcn +NhAAAAAwEAAQAAAgEAx35F099kXRefAFwdezUBqsz2lYRCdUk13bhTOrAZFg0smdsobJfP +4e1WqiiulzcM24PQwbmuO2C4ZN/Wkzd4ahho2Xe5iLfB3yA9bEePNy8qdqKD6K/nbvDnwg +GnnkwMt69VEtUJk2EseqwsNZKiutxtQS45jMgnTZOyIoVEIT/+lMP1auvaJdM3jFBOy8Ka +pVpyLhdj5Ben3v7D6XGCupfqgaiMW37yi5jUNzlMyZAB0OS0JnL9RY4F29Z1AHuCXQzm8s +zSia4ljGVXQSTD9wOHrXk8VSYwwQCGS7T4xnA9M6Q7Ri8ZSyXJ/vEpaMyshnoSPC6LUPBJ +14+SlcnqonDa6FX/9f7OgP9leVeZhEtFTHN5ZDP1IRaqyOwk4vvlhUPzkFpQZAj3fQ0Hvt +CznL/UUwo5RJS9t3HZRH7gjYIcdZT5iMugqzI3E+wcgN8mkCMjTnxBe6L7N1RvpQ5XAr2f +ByQrfW00F2/8LqEYQgP8P7EIK8Fbyd97tJthDwlp2TU7qjpnVKu/2F2aGzbtkd2qLBiJXC +zOcIA43XrOi5QH+VH89CmktHk7OBFIpdlV6YMbdLUvBp4EUZsH3tHMmXkUf4o7/RkXFD15 +D5j/s8iUhNj3NBk3f5jVA8b5sXk/8G/Nop8u0C+FFcNG5FLrbhyldm4L6FR+a0u+yRNqsT +0AAAdA6rcbAuq3GwIAAAAHc3NoLXJzYQAAAgEAx35F099kXRefAFwdezUBqsz2lYRCdUk1 +3bhTOrAZFg0smdsobJfP4e1WqiiulzcM24PQwbmuO2C4ZN/Wkzd4ahho2Xe5iLfB3yA9bE +ePNy8qdqKD6K/nbvDnwgGnnkwMt69VEtUJk2EseqwsNZKiutxtQS45jMgnTZOyIoVEIT/+ +lMP1auvaJdM3jFBOy8KapVpyLhdj5Ben3v7D6XGCupfqgaiMW37yi5jUNzlMyZAB0OS0Jn +L9RY4F29Z1AHuCXQzm8szSia4ljGVXQSTD9wOHrXk8VSYwwQCGS7T4xnA9M6Q7Ri8ZSyXJ +/vEpaMyshnoSPC6LUPBJ14+SlcnqonDa6FX/9f7OgP9leVeZhEtFTHN5ZDP1IRaqyOwk4v +vlhUPzkFpQZAj3fQ0HvtCznL/UUwo5RJS9t3HZRH7gjYIcdZT5iMugqzI3E+wcgN8mkCMj +TnxBe6L7N1RvpQ5XAr2fByQrfW00F2/8LqEYQgP8P7EIK8Fbyd97tJthDwlp2TU7qjpnVK +u/2F2aGzbtkd2qLBiJXCzOcIA43XrOi5QH+VH89CmktHk7OBFIpdlV6YMbdLUvBp4EUZsH +3tHMmXkUf4o7/RkXFD15D5j/s8iUhNj3NBk3f5jVA8b5sXk/8G/Nop8u0C+FFcNG5FLrbh +yldm4L6FR+a0u+yRNqsT0AAAADAQABAAACAA7HwVWB3QN/AxyJdp9g2BYHGAjlXZrSI6/L +nueqUZLTmIzwqBrS7PJsqB8KIxSt3eHAryg/QSmskD+HDF89Z5+YCI1ThGyc+BFM5/LHDX +/ARUQ4umRfGLBHSysm/hEpdMgXhop95rb45uJS3HapUy2xiasKiUsv2XudrapxlxCN7NHU +VUad9qcWnQG5bHvGbWNCuNLHOwh/ToR5eds9+fp9qWtVqR/ZSg/uMODuOaJ1woYEf9Vub8 +CQwHM0fS2IjmolpnxxkPK68z9m6GikQvGg8miM+DDzLVRgsn0sSWyYYcHFpqTxSzgzsqF/ +zXqH2Yt1bWGbQt6qYuovPLl6K4xKU3y9tcNR7ZhsyTI3s0LSD7JCElmzWT7Y7p46rXsPKh +2UdsE3oED1CipU5rY1dEOBDlaN392gRT41WImLFf/O3st6OecKvRJ1AfPRch9v5Ir8/vgi +dH2CPfsadsyMD4A4OnsvlSPWhdseLocCR7WpmZaqyWpogTSBp8eYv52lMMn2lfQ7Unde/9 +/DYRNYQ8UbAFsdvhm0oO+fdq/n9k9/0HzOkkZL9Oxl3R6lKbAQz3zFc4mXwF6Gz1i0pGLQ +U/SmxTgyA/KQPtO+rSeT1dVmm8yieJv/zoOAqtUxkF4ow4SRvOIuGqGzr//gVxxzm2hAfq +Uo56gG3ZyUoZ6gzt6ZAAABAQCG0F0XthEFahVF6jF1cmmH2pdg2Bd06DEYs92r7TJG8BLK +wdVKlNPWSOuS2j9oCoZgbAk5dVnIOxCJClxV+ujZgQyLf2YNGsHywcJftkRAvBl4C7mYWF +ryWSH/5z8q7WNf0vYOAmeBs8jl8v21Z+C4q5jPUF86Opoq3p9+yQJnCf6Y+sOyCG36oD5d +ZGkAtSS0V9vcuwB/ZHxgxGrgskql51w6XumtxkEWab4qWsDs66+X9Z04G54f2vUNBFOPVa +r2h2GczthZT+9zXCu0eh3JRDPcjkTAw6+8Yf5RJiOiuacLvG77V51dSnJkuwsODpK5KEkk +NY+FRQcc4q20jLsBAAABAQD6PsCyDV2e9l+I2NI4BAsE2r7YNw1Iylc83w3qPNWItBWgAL +jdRpk2Sr8Yp7n/CUe9b3s3/DBcPNeVq/biAxABhiShrSWp54FmXe7L1mbVRrA2sM2eV2uy +GrsDlta8etE2Xrtv7PU11VVKvJ4zy8g9WE36yLkZc/JMUhmCkVxFKJLqUYG+S2WwtVlU/v +68pyOlJWfIAaaoE8mFAZKzfWdgIMoTt2voa5eqo3CzH+MqwtmhZL/UCpDdcE7tmqEpe/WM +XAZLTJyD+et5BWC3TLqvYZRXiG4vxHbUSKKAPmnjWV/rGhzYUAMC2TjR8fQPkIqMvbvTKG +Uyyr7ulintDtoFAAABAQDMFLuWegwc5AgnUpd2HKdnWXng37dH8c6HgrEuVzlCZdEGKG8I +g3n+AIechx2jaC0JkpHkC6j9D5rhedBwGw3Z3inYteA2q5KLI+AoJVT1uyaXlDeWDQl3On +TZgnMkoDX9J7a9v3lsYIoFJCfkZHaUJAQKJt5nTjCNTLJ5Pko9nPkxL5hMuiu8l1F0XvyU +eOkjdBiJ2JENjy6ms9oq//6HtOgtt3yDAnJWycSwvsDssluFKzwdpfM6PQRaHG6qTMQrm6 +8zG9zFCTIFIoii27O/v6KSSEhZIhajM3mFY66d1tpnmcs0Y9FzP25LdSwDTeQyEYMsU1Pm +ToOlCkHaEsfZAAAACGhvc3Rfa2V5AQI= +-----END OPENSSH PRIVATE KEY----- diff --git a/src/test/resources/certificates/docker/ssh_host_rsa_key-cert.pub b/src/test/resources/certificates/docker/ssh_host_rsa_key-cert.pub new file mode 100644 index 000000000..c499cc32f --- /dev/null +++ b/src/test/resources/certificates/docker/ssh_host_rsa_key-cert.pub @@ -0,0 +1 @@ +ssh-rsa-cert-v01@openssh.com AAAAHHNzaC1yc2EtY2VydC12MDFAb3BlbnNzaC5jb20AAAAgLCtFMxhu2dw89lLLFt5Z2f5ofwxkhWmyqyQDH/LUYVYAAAADAQABAAACAQDHfkXT32RdF58AXB17NQGqzPaVhEJ1STXduFM6sBkWDSyZ2yhsl8/h7VaqKK6XNwzbg9DBua47YLhk39aTN3hqGGjZd7mIt8HfID1sR483Lyp2ooPor+du8OfCAaeeTAy3r1US1QmTYSx6rCw1kqK63G1BLjmMyCdNk7IihUQhP/6Uw/Vq69ol0zeMUE7LwpqlWnIuF2PkF6fe/sPpcYK6l+qBqIxbfvKLmNQ3OUzJkAHQ5LQmcv1FjgXb1nUAe4JdDObyzNKJriWMZVdBJMP3A4eteTxVJjDBAIZLtPjGcD0zpDtGLxlLJcn+8SlozKyGehI8LotQ8EnXj5KVyeqicNroVf/1/s6A/2V5V5mES0VMc3lkM/UhFqrI7CTi++WFQ/OQWlBkCPd9DQe+0LOcv9RTCjlElL23cdlEfuCNghx1lPmIy6CrMjcT7ByA3yaQIyNOfEF7ovs3VG+lDlcCvZ8HJCt9bTQXb/wuoRhCA/w/sQgrwVvJ33u0m2EPCWnZNTuqOmdUq7/YXZobNu2R3aosGIlcLM5wgDjdes6LlAf5Ufz0KaS0eTs4EUil2VXpgxt0tS8GngRRmwfe0cyZeRR/ijv9GRcUPXkPmP+zyJSE2Pc0GTd/mNUDxvmxeT/wb82iny7QL4UVw0bkUutuHKV2bgvoVH5rS77JE2qxPQAAAAAAAAAAAAAAAgAAAAhob3N0X2tleQAAAAsAAAAHb3BlbnNzaAAAAAAAAAAA//////////8AAAAAAAAAAAAAAAAAAAAzAAAAC3NzaC1lZDI1NTE5AAAAIKT6RkdrPSlmxj4ZizNTSgSK0eu7NU9LgS7hJSBcPsFwAAAAUwAAAAtzc2gtZWQyNTUxOQAAAEB+dLqA7IVOR8tBVJHXckpuZHObKq56/q9Ug2KVzQTVRPE2mUwBuEuE1fRzt2Dk9GyuCsJqiF/9N6AQPllbXQ4I host_key diff --git a/src/test/resources/certificates/docker/ssh_host_rsa_key.pub b/src/test/resources/certificates/docker/ssh_host_rsa_key.pub new file mode 100644 index 000000000..d251c829d --- /dev/null +++ b/src/test/resources/certificates/docker/ssh_host_rsa_key.pub @@ -0,0 +1 @@ +ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAACAQDHfkXT32RdF58AXB17NQGqzPaVhEJ1STXduFM6sBkWDSyZ2yhsl8/h7VaqKK6XNwzbg9DBua47YLhk39aTN3hqGGjZd7mIt8HfID1sR483Lyp2ooPor+du8OfCAaeeTAy3r1US1QmTYSx6rCw1kqK63G1BLjmMyCdNk7IihUQhP/6Uw/Vq69ol0zeMUE7LwpqlWnIuF2PkF6fe/sPpcYK6l+qBqIxbfvKLmNQ3OUzJkAHQ5LQmcv1FjgXb1nUAe4JdDObyzNKJriWMZVdBJMP3A4eteTxVJjDBAIZLtPjGcD0zpDtGLxlLJcn+8SlozKyGehI8LotQ8EnXj5KVyeqicNroVf/1/s6A/2V5V5mES0VMc3lkM/UhFqrI7CTi++WFQ/OQWlBkCPd9DQe+0LOcv9RTCjlElL23cdlEfuCNghx1lPmIy6CrMjcT7ByA3yaQIyNOfEF7ovs3VG+lDlcCvZ8HJCt9bTQXb/wuoRhCA/w/sQgrwVvJ33u0m2EPCWnZNTuqOmdUq7/YXZobNu2R3aosGIlcLM5wgDjdes6LlAf5Ufz0KaS0eTs4EUil2VXpgxt0tS8GngRRmwfe0cyZeRR/ijv9GRcUPXkPmP+zyJSE2Pc0GTd/mNUDxvmxeT/wb82iny7QL4UVw0bkUutuHKV2bgvoVH5rS77JE2qxPQ== host_key diff --git a/src/test/resources/certificates/docker/sshd_config b/src/test/resources/certificates/docker/sshd_config new file mode 100644 index 000000000..54111fbb7 --- /dev/null +++ b/src/test/resources/certificates/docker/sshd_config @@ -0,0 +1,12 @@ +PubkeyAuthentication yes + +AuthenticationMethods publickey + +# PubkeyAcceptedKeyTypes ssh-ed25519, ssh-ed25519-cert-v01@openssh.com, ecdsa-sha2-nistp256,ecdsa-sha2-nistp256-cert-v01@openssh.com, rsa-sha2-512,rsa-sha2-512-cert-v01@openssh.com, rsa-sha2-256, rsa-sha2-256-cert-v01@openssh.com,ssh-rsa, ssh-rsa-cert-v01@openssh.com +PrintMotd no +PermitRootLogin yes +Subsystem sftp internal-sftp +HostKey /etc/ssh/ssh_host_rsa_key +HostCertificate /etc/ssh/ssh_host_rsa_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/dss/root_dsa_key b/src/test/resources/certificates/dss/root_dsa_key new file mode 100644 index 000000000..bf030ea78 --- /dev/null +++ b/src/test/resources/certificates/dss/root_dsa_key @@ -0,0 +1,12 @@ +-----BEGIN DSA PRIVATE KEY----- +MIIBuwIBAAKBgQCruwuluNg4VTvBTYg+zAiqJE03oMmfJcqSCvKIauJBAV0kf8dV +EPvXIpAuQzIYJ/mQH5FZb4c9yROTTMrnsLymQzPvOInDSrpXauN/ED2W8wssGGGM +exAG5pliTbPMjLsQoC6/53Qy9CD13cWdGnlSSCHdSWGB6usuDg6ULSVoewIVAOsB +62PJcOXKehKCrnN0q3OiKZnZAoGAUnX5TigTfvRUn/COlhoRjopjc31hj/Mu3ads +NL9AdIsWff31L3ELOOXgPW03GHD1HY7sIQsUfG96QCt6WOH2sPnBmi7Zz3ajb9ss +UClYEpcBz+vBdT1VVFepHPtDA6t3cOCzX3Q80FOGBLrO1JcI9BVRMTz5qX9YYq+C +LgtbDwkCgYAxZ9mmj2CmkX5f2bHzyfJTkto2G8wpf/pc4cHNMOdSrcaHP9ZTZzeG +tDKjoPSddqkWajgyAebZ6xGz9Q2PlQLLSC+EYj2oTigsw1jt51BeGDXpr6v9JLDP +ehqmA6erImoeeT2dmSRuEl+5A22fDhYvY0vzxXK3Lj6liC0q9uMQbQIVAKvGCKw4 +LA85L+loGn40UdUmfrSE +-----END DSA PRIVATE KEY----- diff --git a/src/test/resources/certificates/dss/root_dsa_key-cert.pub b/src/test/resources/certificates/dss/root_dsa_key-cert.pub new file mode 100644 index 000000000..56e03f30d --- /dev/null +++ b/src/test/resources/certificates/dss/root_dsa_key-cert.pub @@ -0,0 +1 @@ +ssh-dss-cert-v01@openssh.com AAAAHHNzaC1kc3MtY2VydC12MDFAb3BlbnNzaC5jb20AAAAgIIdP/OvhQzSPW55o4R2ZFeu+TsYn/aJKkzT/g273BrYAAACBAKu7C6W42DhVO8FNiD7MCKokTTegyZ8lypIK8ohq4kEBXSR/x1UQ+9cikC5DMhgn+ZAfkVlvhz3JE5NMyuewvKZDM+84icNKuldq438QPZbzCywYYYx7EAbmmWJNs8yMuxCgLr/ndDL0IPXdxZ0aeVJIId1JYYHq6y4ODpQtJWh7AAAAFQDrAetjyXDlynoSgq5zdKtzoimZ2QAAAIBSdflOKBN+9FSf8I6WGhGOimNzfWGP8y7dp2w0v0B0ixZ9/fUvcQs45eA9bTcYcPUdjuwhCxR8b3pAK3pY4faw+cGaLtnPdqNv2yxQKVgSlwHP68F1PVVUV6kc+0MDq3dw4LNfdDzQU4YEus7Ulwj0FVExPPmpf1hir4IuC1sPCQAAAIAxZ9mmj2CmkX5f2bHzyfJTkto2G8wpf/pc4cHNMOdSrcaHP9ZTZzeGtDKjoPSddqkWajgyAebZ6xGz9Q2PlQLLSC+EYj2oTigsw1jt51BeGDXpr6v9JLDPehqmA6erImoeeT2dmSRuEl+5A22fDhYvY0vzxXK3Lj6liC0q9uMQbQAAAAAAAAAAAAAAAQAAABVyb290X2RzYV9rZXktY2VydC5wdWIAAAAIAAAABHJvb3QAAAAAAAAAAP//////////AAAAAAAAAIIAAAAVcGVybWl0LVgxMS1mb3J3YXJkaW5nAAAAAAAAABdwZXJtaXQtYWdlbnQtZm9yd2FyZGluZwAAAAAAAAAWcGVybWl0LXBvcnQtZm9yd2FyZGluZwAAAAAAAAAKcGVybWl0LXB0eQAAAAAAAAAOcGVybWl0LXVzZXItcmMAAAAAAAAAAAAAADMAAAALc3NoLWVkMjU1MTkAAAAgpPpGR2s9KWbGPhmLM1NKBIrR67s1T0uBLuElIFw+wXAAAABTAAAAC3NzaC1lZDI1NTE5AAAAQIRMKra4a32AEK0tESXcDcaGguIUZiHaKrGcjqnQZKc4VgfrYubAdBN9OSPsxorc8Rwse9SfBDb7Iyz+ZuFFxAg= root@jsch diff --git a/src/test/resources/certificates/dss/root_dsa_key.pub b/src/test/resources/certificates/dss/root_dsa_key.pub new file mode 100644 index 000000000..724535c55 --- /dev/null +++ b/src/test/resources/certificates/dss/root_dsa_key.pub @@ -0,0 +1 @@ +ssh-dss AAAAB3NzaC1kc3MAAACBAKu7C6W42DhVO8FNiD7MCKokTTegyZ8lypIK8ohq4kEBXSR/x1UQ+9cikC5DMhgn+ZAfkVlvhz3JE5NMyuewvKZDM+84icNKuldq438QPZbzCywYYYx7EAbmmWJNs8yMuxCgLr/ndDL0IPXdxZ0aeVJIId1JYYHq6y4ODpQtJWh7AAAAFQDrAetjyXDlynoSgq5zdKtzoimZ2QAAAIBSdflOKBN+9FSf8I6WGhGOimNzfWGP8y7dp2w0v0B0ixZ9/fUvcQs45eA9bTcYcPUdjuwhCxR8b3pAK3pY4faw+cGaLtnPdqNv2yxQKVgSlwHP68F1PVVUV6kc+0MDq3dw4LNfdDzQU4YEus7Ulwj0FVExPPmpf1hir4IuC1sPCQAAAIAxZ9mmj2CmkX5f2bHzyfJTkto2G8wpf/pc4cHNMOdSrcaHP9ZTZzeGtDKjoPSddqkWajgyAebZ6xGz9Q2PlQLLSC+EYj2oTigsw1jt51BeGDXpr6v9JLDPehqmA6erImoeeT2dmSRuEl+5A22fDhYvY0vzxXK3Lj6liC0q9uMQbQ== root@jsch diff --git a/src/test/resources/certificates/ecdsa_p256/root_ecdsa_sha2_nistp256_key b/src/test/resources/certificates/ecdsa_p256/root_ecdsa_sha2_nistp256_key new file mode 100644 index 000000000..a7e5c934b --- /dev/null +++ b/src/test/resources/certificates/ecdsa_p256/root_ecdsa_sha2_nistp256_key @@ -0,0 +1,9 @@ +-----BEGIN OPENSSH PRIVATE KEY----- +b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAaAAAABNlY2RzYS +1zaGEyLW5pc3RwMjU2AAAACG5pc3RwMjU2AAAAQQRkaq5BaJvpM5EaSzVMwljb2Iw5zokN +ReyFKcVgrtInxYmE5eldwgEAi7ufNzCpVFbBn9+cHHwyHlPpD4Pvl8J3AAAAqNFdU7HRXV +OxAAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBGRqrkFom+kzkRpL +NUzCWNvYjDnOiQ1F7IUpxWCu0ifFiYTl6V3CAQCLu583MKlUVsGf35wcfDIeU+kPg++Xwn +cAAAAgIGS67O/aW+THSnKSM57umRNDHLH0j97t0S2/wRVJiBkAAAAJcm9vdEBqc2NoAQID +BAUGBw== +-----END OPENSSH PRIVATE KEY----- diff --git a/src/test/resources/certificates/ecdsa_p256/root_ecdsa_sha2_nistp256_key-cert.pub b/src/test/resources/certificates/ecdsa_p256/root_ecdsa_sha2_nistp256_key-cert.pub new file mode 100644 index 000000000..a0c7c5287 --- /dev/null +++ b/src/test/resources/certificates/ecdsa_p256/root_ecdsa_sha2_nistp256_key-cert.pub @@ -0,0 +1 @@ +ecdsa-sha2-nistp256-cert-v01@openssh.com AAAAKGVjZHNhLXNoYTItbmlzdHAyNTYtY2VydC12MDFAb3BlbnNzaC5jb20AAAAgRKvdnrNUwLYg6XXTN7ZjjPO+peDU5tisQvPBCH3H8FQAAAAIbmlzdHAyNTYAAABBBGRqrkFom+kzkRpLNUzCWNvYjDnOiQ1F7IUpxWCu0ifFiYTl6V3CAQCLu583MKlUVsGf35wcfDIeU+kPg++XwncAAAAAAAAAAAAAAAEAAAAdcm9vdF9lY2RzYV9zaGEyX25pc3RwMjU2X2NlcnQAAAAIAAAABHJvb3QAAAAAAAAAAP//////////AAAAAAAAAIIAAAAVcGVybWl0LVgxMS1mb3J3YXJkaW5nAAAAAAAAABdwZXJtaXQtYWdlbnQtZm9yd2FyZGluZwAAAAAAAAAWcGVybWl0LXBvcnQtZm9yd2FyZGluZwAAAAAAAAAKcGVybWl0LXB0eQAAAAAAAAAOcGVybWl0LXVzZXItcmMAAAAAAAAAAAAAADMAAAALc3NoLWVkMjU1MTkAAAAgpPpGR2s9KWbGPhmLM1NKBIrR67s1T0uBLuElIFw+wXAAAABTAAAAC3NzaC1lZDI1NTE5AAAAQNWKoY98XF3JQsS4imfv/pzFNaU7mxbC4PbsAwl5myz8axaJLBIfqP4fFB0tpuB9u99PGrFqe+JUGw02kWXXlwE= root@jsch diff --git a/src/test/resources/certificates/ecdsa_p256/root_ecdsa_sha2_nistp256_key.pub b/src/test/resources/certificates/ecdsa_p256/root_ecdsa_sha2_nistp256_key.pub new file mode 100644 index 000000000..ee7acc472 --- /dev/null +++ b/src/test/resources/certificates/ecdsa_p256/root_ecdsa_sha2_nistp256_key.pub @@ -0,0 +1 @@ +ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBGRqrkFom+kzkRpLNUzCWNvYjDnOiQ1F7IUpxWCu0ifFiYTl6V3CAQCLu583MKlUVsGf35wcfDIeU+kPg++Xwnc= root@jsch diff --git a/src/test/resources/certificates/ecdsa_p384/root_ecdsa-sha2-nistp384_key b/src/test/resources/certificates/ecdsa_p384/root_ecdsa-sha2-nistp384_key new file mode 100644 index 000000000..779f8dfd2 --- /dev/null +++ b/src/test/resources/certificates/ecdsa_p384/root_ecdsa-sha2-nistp384_key @@ -0,0 +1,10 @@ +-----BEGIN OPENSSH PRIVATE KEY----- +b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAiAAAABNlY2RzYS +1zaGEyLW5pc3RwMzg0AAAACG5pc3RwMzg0AAAAYQRPJ3mj4Vp+co/OuPhMpXWrG/FcqRxc +3c5Wc+n+X9jTgVk4S3okgl3DBib43ZRnABAD2ZMylrQQnTYcxiT+wXeRZQCS61z/NP8XHu +sE+i0GE1zeBFdD5akg3O5XQtvHzQwAAADY0Or4IdDq+CEAAAATZWNkc2Etc2hhMi1uaXN0 +cDM4NAAAAAhuaXN0cDM4NAAAAGEETyd5o+FafnKPzrj4TKV1qxvxXKkcXN3OVnPp/l/Y04 +FZOEt6JIJdwwYm+N2UZwAQA9mTMpa0EJ02HMYk/sF3kWUAkutc/zT/Fx7rBPotBhNc3gRX +Q+WpINzuV0Lbx80MAAAAMQCwU7ixUtk0z4smYo44MySZrfTJuOC0fnWrO8NZJayjBLUW0x +ELNDuwbaKRhSP+odAAAAAJcm9vdEBqc2NoAQIDBAUG +-----END OPENSSH PRIVATE KEY----- diff --git a/src/test/resources/certificates/ecdsa_p384/root_ecdsa-sha2-nistp384_key-cert.pub b/src/test/resources/certificates/ecdsa_p384/root_ecdsa-sha2-nistp384_key-cert.pub new file mode 100644 index 000000000..90e8bd313 --- /dev/null +++ b/src/test/resources/certificates/ecdsa_p384/root_ecdsa-sha2-nistp384_key-cert.pub @@ -0,0 +1 @@ +ecdsa-sha2-nistp384-cert-v01@openssh.com AAAAKGVjZHNhLXNoYTItbmlzdHAzODQtY2VydC12MDFAb3BlbnNzaC5jb20AAAAgOjGNjBgPpGzVUvbu3dcSzPedDvOFIZz5uq58x4IIvA4AAAAIbmlzdHAzODQAAABhBE8neaPhWn5yj864+Eyldasb8VypHFzdzlZz6f5f2NOBWThLeiSCXcMGJvjdlGcAEAPZkzKWtBCdNhzGJP7Bd5FlAJLrXP80/xce6wT6LQYTXN4EV0PlqSDc7ldC28fNDAAAAAAAAAAAAAAAAQAAAB1yb290X2VjZHNhX3NoYTJfbmlzdHAzODRfY2VydAAAAAgAAAAEcm9vdAAAAAAAAAAA//////////8AAAAAAAAAggAAABVwZXJtaXQtWDExLWZvcndhcmRpbmcAAAAAAAAAF3Blcm1pdC1hZ2VudC1mb3J3YXJkaW5nAAAAAAAAABZwZXJtaXQtcG9ydC1mb3J3YXJkaW5nAAAAAAAAAApwZXJtaXQtcHR5AAAAAAAAAA5wZXJtaXQtdXNlci1yYwAAAAAAAAAAAAAAMwAAAAtzc2gtZWQyNTUxOQAAACCk+kZHaz0pZsY+GYszU0oEitHruzVPS4Eu4SUgXD7BcAAAAFMAAAALc3NoLWVkMjU1MTkAAABAGzsuog2FwQnJwi7d0MfCZFkINAjubRDR7D88GQ14uGR8+uQZhz0ijVYXVEbGXQxPulbZNHE88VTHOjqzCp8LCw== root@jsch diff --git a/src/test/resources/certificates/ecdsa_p384/root_ecdsa-sha2_nistp384_key.pub b/src/test/resources/certificates/ecdsa_p384/root_ecdsa-sha2_nistp384_key.pub new file mode 100644 index 000000000..598ce2e85 --- /dev/null +++ b/src/test/resources/certificates/ecdsa_p384/root_ecdsa-sha2_nistp384_key.pub @@ -0,0 +1 @@ +ecdsa-sha2-nistp384 AAAAE2VjZHNhLXNoYTItbmlzdHAzODQAAAAIbmlzdHAzODQAAABhBE8neaPhWn5yj864+Eyldasb8VypHFzdzlZz6f5f2NOBWThLeiSCXcMGJvjdlGcAEAPZkzKWtBCdNhzGJP7Bd5FlAJLrXP80/xce6wT6LQYTXN4EV0PlqSDc7ldC28fNDA== root@jsch diff --git a/src/test/resources/certificates/ecdsa_p521/root_ecdsa_sha2_nistp521_key b/src/test/resources/certificates/ecdsa_p521/root_ecdsa_sha2_nistp521_key new file mode 100644 index 000000000..c40d38f20 --- /dev/null +++ b/src/test/resources/certificates/ecdsa_p521/root_ecdsa_sha2_nistp521_key @@ -0,0 +1,12 @@ +-----BEGIN OPENSSH PRIVATE KEY----- +b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAArAAAABNlY2RzYS +1zaGEyLW5pc3RwNTIxAAAACG5pc3RwNTIxAAAAhQQAGyUjNEMH3/FviSGY0ll7Je2St1o+ +GNssqOUdnHIERjGNS8sudFSRsTkBwYwO8cFcfan3HztLydIV8CE/oTuuJD4ASTAKx4hT+N +kkonjAZ4munfyybshUUhmrzgCNThfG7oun44b6UzbeEqn/0zSrr857vyP8/11EqlsfAFA4 +oSrU0kQAAAEIPa26xT2tusUAAAATZWNkc2Etc2hhMi1uaXN0cDUyMQAAAAhuaXN0cDUyMQ +AAAIUEABslIzRDB9/xb4khmNJZeyXtkrdaPhjbLKjlHZxyBEYxjUvLLnRUkbE5AcGMDvHB +XH2p9x87S8nSFfAhP6E7riQ+AEkwCseIU/jZJKJ4wGeJrp38sm7IVFIZq84AjU4Xxu6Lp+ +OG+lM23hKp/9M0q6/Oe78j/P9dRKpbHwBQOKEq1NJEAAAAQgHfR86PaqYxkl28+uAukdu6 +tE6dTgap5kMrkkjydRze8Pbb3ARCUdXInIATFTV8vKSOokH72JPdhqOu2idGB3jtwwAAAA +lyb290QGpzY2gB +-----END OPENSSH PRIVATE KEY----- diff --git a/src/test/resources/certificates/ecdsa_p521/root_ecdsa_sha2_nistp521_key-cert.pub b/src/test/resources/certificates/ecdsa_p521/root_ecdsa_sha2_nistp521_key-cert.pub new file mode 100644 index 000000000..4cbaf7ca5 --- /dev/null +++ b/src/test/resources/certificates/ecdsa_p521/root_ecdsa_sha2_nistp521_key-cert.pub @@ -0,0 +1 @@ +ecdsa-sha2-nistp521-cert-v01@openssh.com AAAAKGVjZHNhLXNoYTItbmlzdHA1MjEtY2VydC12MDFAb3BlbnNzaC5jb20AAAAgnCOPG+mG47nRTN6XgXwlTFkA274OJzZMK16Bocbj6hUAAAAIbmlzdHA1MjEAAACFBAAbJSM0Qwff8W+JIZjSWXsl7ZK3Wj4Y2yyo5R2ccgRGMY1Lyy50VJGxOQHBjA7xwVx9qfcfO0vJ0hXwIT+hO64kPgBJMArHiFP42SSieMBnia6d/LJuyFRSGavOAI1OF8bui6fjhvpTNt4Sqf/TNKuvznu/I/z/XUSqWx8AUDihKtTSRAAAAAAAAAAAAAAAAQAAAB1yb290X2VjZHNhX3NoYTJfbmlzdHA1MjFfY2VydAAAAAgAAAAEcm9vdAAAAAAAAAAA//////////8AAAAAAAAAggAAABVwZXJtaXQtWDExLWZvcndhcmRpbmcAAAAAAAAAF3Blcm1pdC1hZ2VudC1mb3J3YXJkaW5nAAAAAAAAABZwZXJtaXQtcG9ydC1mb3J3YXJkaW5nAAAAAAAAAApwZXJtaXQtcHR5AAAAAAAAAA5wZXJtaXQtdXNlci1yYwAAAAAAAAAAAAAAMwAAAAtzc2gtZWQyNTUxOQAAACCk+kZHaz0pZsY+GYszU0oEitHruzVPS4Eu4SUgXD7BcAAAAFMAAAALc3NoLWVkMjU1MTkAAABACTmzwTLZIrb/+WAX97LzgkkVtmUGqGBSd5cAfdTSewW0I7F0nnrfiWxHKxAGMMu8gCuei//fxU/j0CvZ5zOhCA== root@jsch diff --git a/src/test/resources/certificates/ecdsa_p521/root_ecdsa_sha2_nistp521_key.pub b/src/test/resources/certificates/ecdsa_p521/root_ecdsa_sha2_nistp521_key.pub new file mode 100644 index 000000000..28a7b6bb9 --- /dev/null +++ b/src/test/resources/certificates/ecdsa_p521/root_ecdsa_sha2_nistp521_key.pub @@ -0,0 +1 @@ +ecdsa-sha2-nistp521 AAAAE2VjZHNhLXNoYTItbmlzdHA1MjEAAAAIbmlzdHA1MjEAAACFBAAbJSM0Qwff8W+JIZjSWXsl7ZK3Wj4Y2yyo5R2ccgRGMY1Lyy50VJGxOQHBjA7xwVx9qfcfO0vJ0hXwIT+hO64kPgBJMArHiFP42SSieMBnia6d/LJuyFRSGavOAI1OF8bui6fjhvpTNt4Sqf/TNKuvznu/I/z/XUSqWx8AUDihKtTSRA== root@jsch diff --git a/src/test/resources/certificates/ed25519/root_ed25519_key b/src/test/resources/certificates/ed25519/root_ed25519_key new file mode 100644 index 000000000..db5c1b509 --- /dev/null +++ b/src/test/resources/certificates/ed25519/root_ed25519_key @@ -0,0 +1,7 @@ +-----BEGIN OPENSSH PRIVATE KEY----- +b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW +QyNTUxOQAAACABYJVVa/zEZPnSZIMhe1V94rAnkMefiP3nprDl2H7t0AAAAJALg5pGC4Oa +RgAAAAtzc2gtZWQyNTUxOQAAACABYJVVa/zEZPnSZIMhe1V94rAnkMefiP3nprDl2H7t0A +AAAECV6JukBCf/UE+fYisTqvFTeZjP0W7/bksYPFLzdy6lBwFglVVr/MRk+dJkgyF7VX3i +sCeQx5+I/eemsOXYfu3QAAAACXJvb3RAanNjaAECAwQ= +-----END OPENSSH PRIVATE KEY----- diff --git a/src/test/resources/certificates/ed25519/root_ed25519_key-cert.pub b/src/test/resources/certificates/ed25519/root_ed25519_key-cert.pub new file mode 100644 index 000000000..45016e2c7 --- /dev/null +++ b/src/test/resources/certificates/ed25519/root_ed25519_key-cert.pub @@ -0,0 +1 @@ +ssh-ed25519-cert-v01@openssh.com AAAAIHNzaC1lZDI1NTE5LWNlcnQtdjAxQG9wZW5zc2guY29tAAAAIL9+pDLW84sRnn60gmIDrLh85yfgepZa8ONLfseWaKiIAAAAIAFglVVr/MRk+dJkgyF7VX3isCeQx5+I/eemsOXYfu3QAAAAAAAAAAAAAAABAAAAEXJvb3RfZWQyNTUxOV9jZXJ0AAAACAAAAARyb290AAAAAAAAAAD//////////wAAAAAAAACCAAAAFXBlcm1pdC1YMTEtZm9yd2FyZGluZwAAAAAAAAAXcGVybWl0LWFnZW50LWZvcndhcmRpbmcAAAAAAAAAFnBlcm1pdC1wb3J0LWZvcndhcmRpbmcAAAAAAAAACnBlcm1pdC1wdHkAAAAAAAAADnBlcm1pdC11c2VyLXJjAAAAAAAAAAAAAAAzAAAAC3NzaC1lZDI1NTE5AAAAIKT6RkdrPSlmxj4ZizNTSgSK0eu7NU9LgS7hJSBcPsFwAAAAUwAAAAtzc2gtZWQyNTUxOQAAAEB5w7ox8RcBxmH49p81T7bHWuQ8sbTa0Gr+XMerAjewDWtIFUuUc0iu4dBXuHfOI3+kxCN3RAKI/Ncli1sfd1gO root@jsch diff --git a/src/test/resources/certificates/ed25519/root_ed25519_key.pub b/src/test/resources/certificates/ed25519/root_ed25519_key.pub new file mode 100644 index 000000000..6892aaf71 --- /dev/null +++ b/src/test/resources/certificates/ed25519/root_ed25519_key.pub @@ -0,0 +1 @@ +ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIAFglVVr/MRk+dJkgyF7VX3isCeQx5+I/eemsOXYfu3Q root@jsch diff --git a/src/test/resources/certificates/rsa/root_rsa_key b/src/test/resources/certificates/rsa/root_rsa_key new file mode 100644 index 000000000..162895b36 --- /dev/null +++ b/src/test/resources/certificates/rsa/root_rsa_key @@ -0,0 +1,49 @@ +-----BEGIN OPENSSH PRIVATE KEY----- +b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAACFwAAAAdzc2gtcn +NhAAAAAwEAAQAAAgEAthMKFgB3n3f6H+2W5M9ZTFlL2yS5i4/YL4uDY3D5l+4Tu+fBLdp9 +wmCqtGUa3+2WfQ3aCZlslxv3ZRODcitL05zY4B0B592wW2HW8/hygZErs72ESRyUmCa4Xy +arFEcCo28iWsiseCxcyv7QL3sLnFQpuVO69Ri/knK7JZVX7u+CMDQk7ThnkmEP3qZJaYU2 +vACCy3Scw48q2B9GQPW4ilwXvTju5izLQfbgGX+qaz1USu/0DzfNyN3tuxfambQENhToXk +fAbtBbccpipXLJDLjQMcXAwe7oN+Uc/Pd+Oe2fHxRrBOfCKUjbbh019F3/EYd355pPJVuU +s6T+nPzH0/55XcPGlWagk7esHjrBEsaehzw5DaCuC3FsGnetSff9o1BXjk9VGV7n7PNutP +j9nNdfMTMDlmYygpnQIUvT+gxAyP1tExc89bpuUdOmCN4NDsdPo19obzKQ3Ln0PDliuWtX +CLwyM9ccBn3xt496kg0Nt1kmiOWbTPHGqj98kMfveBa3dPmTzm97rCfDiDU93wE6GqHzPR +WnU2xQJQle42Dsff+qL9aocwcXHnKZdCPW9hLkvEy0KzcEFxlnjEcjxRNsq7AoUQTz1Cy5 +3ElqdqMie10cZj+6LQkaPZEr3/muzvvvSuAhQToV5AcTk2oDy1u2IMbvyZc4/zFkP7oyZ5 +kAAAdARQTSLUUE0i0AAAAHc3NoLXJzYQAAAgEAthMKFgB3n3f6H+2W5M9ZTFlL2yS5i4/Y +L4uDY3D5l+4Tu+fBLdp9wmCqtGUa3+2WfQ3aCZlslxv3ZRODcitL05zY4B0B592wW2HW8/ +hygZErs72ESRyUmCa4XyarFEcCo28iWsiseCxcyv7QL3sLnFQpuVO69Ri/knK7JZVX7u+C +MDQk7ThnkmEP3qZJaYU2vACCy3Scw48q2B9GQPW4ilwXvTju5izLQfbgGX+qaz1USu/0Dz +fNyN3tuxfambQENhToXkfAbtBbccpipXLJDLjQMcXAwe7oN+Uc/Pd+Oe2fHxRrBOfCKUjb +bh019F3/EYd355pPJVuUs6T+nPzH0/55XcPGlWagk7esHjrBEsaehzw5DaCuC3FsGnetSf +f9o1BXjk9VGV7n7PNutPj9nNdfMTMDlmYygpnQIUvT+gxAyP1tExc89bpuUdOmCN4NDsdP +o19obzKQ3Ln0PDliuWtXCLwyM9ccBn3xt496kg0Nt1kmiOWbTPHGqj98kMfveBa3dPmTzm +97rCfDiDU93wE6GqHzPRWnU2xQJQle42Dsff+qL9aocwcXHnKZdCPW9hLkvEy0KzcEFxln +jEcjxRNsq7AoUQTz1Cy53ElqdqMie10cZj+6LQkaPZEr3/muzvvvSuAhQToV5AcTk2oDy1 +u2IMbvyZc4/zFkP7oyZ5kAAAADAQABAAACABIgKJmin8YCF2YszKAILVNes2DEUe1dG1A2 +YxTX9x0DIDdNXcufC/R5E51kUE3ZFOlrothgS/FqIRGQpP4NZd3R6Dw9XwZyaR9byN6eTe +Xsqg1ZcU0m+XsBJcshKhhZbl+PTXv8rMDE59L0lYyYgwIj1ciDl6HVPiMJ5WwbJzcb5FnA +oylze0oU/BO1+ap1zspeAadX+1AzlTgROvj2DVJG0z0s6QmEjZKKTWp0bWaCIz1XkHQgYc +3cnBFLUWGKTH4GzFAwc+2ENApKs9HVMMMhteYykdssmbUQy7c6Ozhwma3qJjJFkSxzepbG +dSvX3K/lzM/DapwlNxNAOnGt74WV+vXjslRl+Fu5NrtgHeQC0/QdvDAdG+aBibxaP+L9m5 +k5jUmq5jSL+9KDDvC/9WlcY3g3Wv3EVGXw0qqh8iw1MDX4yH8VrMHJ+svsDCVp8rOsRJC7 +3crDm4hiCByKbWYb94aKsrAMbIwVIH1LEsW9cgLuZWhQCWyqqbDURMCYuFQKVF5dwRj/pQ +YU7pLO6JOXYjouKwGNmkm9g5uGrTADDLfmWEV+b8xTOXt/oSnWn1Hb/089COcxc/iUyDnl +hhwj6ER9DOfxN5bK8WBT7A03pm29Ke/x1UXHMN6hokFFDmUGY2kzjsB3o1p/c/HWgEKCsQ +t+r8l56zYD8p4KtLJ1AAABAGjgbYwLvqxDJPeD+u801EvMVEAnYfxbygMGTxnP4bDuViaz +VxBTv9Lcaw2dmxLlVMLXcbFtkSfb+mmUnPu7AJspKduj2FTd1J61ewJEnOb/7X3BYSvuUw +3Ac02gOv+NbVy6y33d5S0d4zhlMdybGsrK4vEbyAEYoUXvYrj7mfwrz5MN/1y3Z0TPoszk +qy+TC3W40f25YJ3lG4np+b8fHFitHqtv6tldmV7CVnwjE391St5vUZUC/3eEJxRt/LUF5f +lUV6XFeTkZ93oZq3KaKhj7VYfL6pA5Oz/zQswIAFErBntgwF0bZioheU59w7GNUeZnGlaW +SW1gTGWfVXNU6UkAAAEBANsbalouB3wT7ASLT3raEybnace3Xc4/EIiMrSTS3baPSiP64X +3xXI80T9qculS1uPDo15wTAUnx6FNHEQ7fnfk3XEFdHxL2a7fdnMMnVefisI0Q2Wd2/uLT +3/3eUUbE72Ne9WZKi6cE4jyyin4rckaQBDzVQSIV1/Co7iHkpbXaJFXeRTsmhgLWkkPPP4 +XdAZI7VsLwYKT7n0tvMxGSIHGHwV5Z9Sr3mIhQbcCktf/nyAh0utmvYDJrlIGxQ9Yd7wiP ++OfSY4XIgTfgd6MjdKfH+A/Rp1y+16QW6Wn9LnwlT/dhtPt/5X8jlL9VvmYrCZhQOnzyjF +lhB11HGfovR10AAAEBANS7VUa5YhrSbsMhGTDCukMBrgBW9lnTEACryzim/eNztf7GyUcm +BJcbCTZ3ccNcaLq/bVLQZv4rdlWeSILYb4W5Oa+YelyWXuXFVuOVhwVYnYoif4REj03GRH +gvIB+xktkLbfALFFV2B3oDCWrPE9agr3SNDTPweKjvD69PqpVtKVmnyaamYlx1UdiMp/nO +2YfrJz79iR+PzTWcvN4Uo3TuiJ4MxnLcx70LgVwRJwhVdHsGZYNMCmw6LhpQcWV6CJ6tFj +0ucc2f9ZP5ItXWveBA+6RVxvMFjP8gCdh0khFmX/CX18F0f4yc+xeoR2WGxKYxXLDrbbFV +gOkwe4WdyW0AAAAJcm9vdEBqc2NoAQI= +-----END OPENSSH PRIVATE KEY----- diff --git a/src/test/resources/certificates/rsa/root_rsa_key-cert.pub b/src/test/resources/certificates/rsa/root_rsa_key-cert.pub new file mode 100644 index 000000000..b2d1983e5 --- /dev/null +++ b/src/test/resources/certificates/rsa/root_rsa_key-cert.pub @@ -0,0 +1 @@ +ssh-rsa-cert-v01@openssh.com AAAAHHNzaC1yc2EtY2VydC12MDFAb3BlbnNzaC5jb20AAAAg8RWHHkK0L3imXXV3i9Bgon7gqasjXsVM24QsOgzzWUMAAAADAQABAAACAQC2EwoWAHefd/of7Zbkz1lMWUvbJLmLj9gvi4NjcPmX7hO758Et2n3CYKq0ZRrf7ZZ9DdoJmWyXG/dlE4NyK0vTnNjgHQHn3bBbYdbz+HKBkSuzvYRJHJSYJrhfJqsURwKjbyJayKx4LFzK/tAvewucVCm5U7r1GL+ScrsllVfu74IwNCTtOGeSYQ/epklphTa8AILLdJzDjyrYH0ZA9biKXBe9OO7mLMtB9uAZf6prPVRK7/QPN83I3e27F9qZtAQ2FOheR8Bu0FtxymKlcskMuNAxxcDB7ug35Rz893457Z8fFGsE58IpSNtuHTX0Xf8Rh3fnmk8lW5SzpP6c/MfT/nldw8aVZqCTt6weOsESxp6HPDkNoK4LcWwad61J9/2jUFeOT1UZXufs8260+P2c118xMwOWZjKCmdAhS9P6DEDI/W0TFzz1um5R06YI3g0Ox0+jX2hvMpDcufQ8OWK5a1cIvDIz1xwGffG3j3qSDQ23WSaI5ZtM8caqP3yQx+94Frd0+ZPOb3usJ8OINT3fAToaofM9FadTbFAlCV7jYOx9/6ov1qhzBxcecpl0I9b2EuS8TLQrNwQXGWeMRyPFE2yrsChRBPPULLncSWp2oyJ7XRxmP7otCRo9kSvf+a7O++9K4CFBOhXkBxOTagPLW7Ygxu/Jlzj/MWQ/ujJnmQAAAAAAAAAAAAAAAQAAABVyb290X3JzYV9rZXktY2VydC5wdWIAAAAIAAAABHJvb3QAAAAAAAAAAP//////////AAAAAAAAAIIAAAAVcGVybWl0LVgxMS1mb3J3YXJkaW5nAAAAAAAAABdwZXJtaXQtYWdlbnQtZm9yd2FyZGluZwAAAAAAAAAWcGVybWl0LXBvcnQtZm9yd2FyZGluZwAAAAAAAAAKcGVybWl0LXB0eQAAAAAAAAAOcGVybWl0LXVzZXItcmMAAAAAAAAAAAAAADMAAAALc3NoLWVkMjU1MTkAAAAgpPpGR2s9KWbGPhmLM1NKBIrR67s1T0uBLuElIFw+wXAAAABTAAAAC3NzaC1lZDI1NTE5AAAAQIrR9u2miSHH+BCG8qtGPdHUizjsCctrrqsaTCiFXiZKfs7pM9if1IfArYVnRPpKstycd+L8KAwHUiMayN8JlgA= root@jsch diff --git a/src/test/resources/certificates/rsa/root_rsa_key.pub b/src/test/resources/certificates/rsa/root_rsa_key.pub new file mode 100644 index 000000000..1a25619b9 --- /dev/null +++ b/src/test/resources/certificates/rsa/root_rsa_key.pub @@ -0,0 +1 @@ +ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAACAQC2EwoWAHefd/of7Zbkz1lMWUvbJLmLj9gvi4NjcPmX7hO758Et2n3CYKq0ZRrf7ZZ9DdoJmWyXG/dlE4NyK0vTnNjgHQHn3bBbYdbz+HKBkSuzvYRJHJSYJrhfJqsURwKjbyJayKx4LFzK/tAvewucVCm5U7r1GL+ScrsllVfu74IwNCTtOGeSYQ/epklphTa8AILLdJzDjyrYH0ZA9biKXBe9OO7mLMtB9uAZf6prPVRK7/QPN83I3e27F9qZtAQ2FOheR8Bu0FtxymKlcskMuNAxxcDB7ug35Rz893457Z8fFGsE58IpSNtuHTX0Xf8Rh3fnmk8lW5SzpP6c/MfT/nldw8aVZqCTt6weOsESxp6HPDkNoK4LcWwad61J9/2jUFeOT1UZXufs8260+P2c118xMwOWZjKCmdAhS9P6DEDI/W0TFzz1um5R06YI3g0Ox0+jX2hvMpDcufQ8OWK5a1cIvDIz1xwGffG3j3qSDQ23WSaI5ZtM8caqP3yQx+94Frd0+ZPOb3usJ8OINT3fAToaofM9FadTbFAlCV7jYOx9/6ov1qhzBxcecpl0I9b2EuS8TLQrNwQXGWeMRyPFE2yrsChRBPPULLncSWp2oyJ7XRxmP7otCRo9kSvf+a7O++9K4CFBOhXkBxOTagPLW7Ygxu/Jlzj/MWQ/ujJnmQ== root@jsch From 17d933b141b42fcd01d5d01995bb10981b52f0a7 Mon Sep 17 00:00:00 2001 From: Luigi De Masi Date: Thu, 18 Sep 2025 16:19:18 +0200 Subject: [PATCH 2/6] Add support for OpenSSH certificates, resolve #31 - Fixes after code review --- src/main/java/com/jcraft/jsch/Buffer.java | 6 +-- src/main/java/com/jcraft/jsch/JSch.java | 42 +++++++++--------- .../com/jcraft/jsch/OpenSshCertificate.java | 2 +- .../OpenSshCertificateAwareIdentityFile.java | 5 ++- .../jcraft/jsch/OpenSshCertificateBuffer.java | 15 ++++--- .../jcraft/jsch/OpenSshCertificateUtil.java | 44 +++++++------------ .../jcraft/jsch/OpensshCertificateParser.java | 7 ++- 7 files changed, 58 insertions(+), 63 deletions(-) diff --git a/src/main/java/com/jcraft/jsch/Buffer.java b/src/main/java/com/jcraft/jsch/Buffer.java index f2b4f1fb0..4623555e1 100644 --- a/src/main/java/com/jcraft/jsch/Buffer.java +++ b/src/main/java/com/jcraft/jsch/Buffer.java @@ -28,17 +28,17 @@ public class Buffer { final byte[] tmp = new byte[4]; - protected byte[] buffer; + byte[] buffer; // write position // Tracks the current writing position in the buffer and points to where the next // write operation will occur and increments as data is written - protected int index; + int index; // read position - Tracks the current reading position in the buffer and points to where the next // read operation will // occur and increments as data is read - protected int s; + int s; public Buffer(int size) { buffer = new byte[size]; diff --git a/src/main/java/com/jcraft/jsch/JSch.java b/src/main/java/com/jcraft/jsch/JSch.java index a4638e0b7..20872737b 100644 --- a/src/main/java/com/jcraft/jsch/JSch.java +++ b/src/main/java/com/jcraft/jsch/JSch.java @@ -245,9 +245,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,ecdsa-sha2-nistp256,ecdsa-sha2-nistp384,ecdsa-sha2-nistp521,rsa-sha2-512,rsa-sha2-256," - + "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-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")); config.put("enable_pubkey_auth_query", Util.getSystemProperty("jsch.enable_pubkey_auth_query", "yes")); config.put("try_additional_pubkey_algorithms", @@ -331,8 +329,8 @@ public JSch() {} * @return the instance of Session class. * @throws JSchException if username or host are invalid. * @see #getSession(String username, String host, int port) - * @see com.jcraft.jsch.Session - * @see com.jcraft.jsch.ConfigRepository + * @see Session + * @see ConfigRepository */ public Session getSession(String host) throws JSchException { return getSession(null, host, 22); @@ -348,7 +346,7 @@ public Session getSession(String host) throws JSchException { * @return the instance of Session class. * @throws JSchException if username or host are invalid. * @see #getSession(String username, String host, int port) - * @see com.jcraft.jsch.Session + * @see Session */ public Session getSession(String username, String host) throws JSchException { return getSession(username, host, 22); @@ -365,7 +363,7 @@ public Session getSession(String username, String host) throws JSchException { * @return the instance of Session class. * @throws JSchException if username or host are invalid. * @see #getSession(String username, String host, int port) - * @see com.jcraft.jsch.Session + * @see Session */ public Session getSession(String username, String host, int port) throws JSchException { if (host == null) { @@ -391,8 +389,8 @@ protected boolean removeSession(Session session) { * Sets the hostkey repository. * * @param hkrepo - * @see com.jcraft.jsch.HostKeyRepository - * @see com.jcraft.jsch.KnownHosts + * @see HostKeyRepository + * @see KnownHosts */ public void setHostKeyRepository(HostKeyRepository hkrepo) { known_hosts = hkrepo; @@ -403,7 +401,7 @@ public void setHostKeyRepository(HostKeyRepository hkrepo) { * * @param filename filename of known_hosts file. * @throws JSchException if the given filename is invalid. - * @see com.jcraft.jsch.KnownHosts + * @see KnownHosts */ public void setKnownHosts(String filename) throws JSchException { if (known_hosts == null) @@ -420,7 +418,7 @@ public void setKnownHosts(String filename) throws JSchException { * * @param stream the instance of InputStream from known_hosts file. * @throws JSchException if an I/O error occurs. - * @see com.jcraft.jsch.KnownHosts + * @see KnownHosts */ public void setKnownHosts(InputStream stream) throws JSchException { if (known_hosts == null) @@ -437,8 +435,8 @@ public void setKnownHosts(InputStream stream) throws JSchException { * KnownHosts. * * @return current hostkey repository. - * @see com.jcraft.jsch.HostKeyRepository - * @see com.jcraft.jsch.KnownHosts + * @see HostKeyRepository + * @see KnownHosts */ public HostKeyRepository getHostKeyRepository() { if (known_hosts == null) @@ -500,19 +498,21 @@ 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; + String pubkeyFileContent = null; Identity identity; - try { - pubkeyFileContent = new String(Util.fromFile(pubkey), UTF_8); - } catch (IOException e) { - throw new JSchException(e.toString(), e); + if (pubkey != null) { + try { + pubkeyFileContent = new String(Util.fromFile(pubkey), UTF_8); + } catch (IOException e) { + throw new JSchException(e.toString(), e); + } } - if (isOpenSshCertificate(pubkeyFileContent)) { + if (pubkeyFileContent != null && isOpenSshCertificate(pubkeyFileContent)) { identity = OpenSshCertificateAwareIdentityFile.newInstance(prvkey, pubkey, instLogger); } else { - identity = IdentityFile.newInstance(prvkey, pubkey, instLogger); + identity = IdentityFile.newInstance(prvkey, pubkeyFileContent, instLogger); } addIdentity(identity, passphrase); @@ -694,7 +694,7 @@ public static Map getConfig() { * Sets the logger * * @param logger logger or null if no logging should take place - * @see com.jcraft.jsch.Logger + * @see Logger */ public static void setLogger(Logger logger) { if (logger == null) diff --git a/src/main/java/com/jcraft/jsch/OpenSshCertificate.java b/src/main/java/com/jcraft/jsch/OpenSshCertificate.java index 7c00ad479..3b228e18c 100644 --- a/src/main/java/com/jcraft/jsch/OpenSshCertificate.java +++ b/src/main/java/com/jcraft/jsch/OpenSshCertificate.java @@ -21,7 +21,7 @@ * @see OpenSSH Certificate * Protocol */ -public class OpenSshCertificate { +class OpenSshCertificate { /** * Certificate type constant for user certificates diff --git a/src/main/java/com/jcraft/jsch/OpenSshCertificateAwareIdentityFile.java b/src/main/java/com/jcraft/jsch/OpenSshCertificateAwareIdentityFile.java index bc8d9b788..da2503f15 100644 --- a/src/main/java/com/jcraft/jsch/OpenSshCertificateAwareIdentityFile.java +++ b/src/main/java/com/jcraft/jsch/OpenSshCertificateAwareIdentityFile.java @@ -25,7 +25,7 @@ * provides all the functionality needed for SSH authentication using certificates. *

*/ -public class OpenSshCertificateAwareIdentityFile implements Identity { +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_DSS_CERT_V01_AT_OPENSSH_DOT_COM = "ssh-dss-cert-v01@openssh.com"; @@ -37,6 +37,8 @@ public class OpenSshCertificateAwareIdentityFile implements Identity { "ecdsa-sha2-nistp521-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_ED448_CERT_V01_AT_OPENSSH_DOT_COM = + "ssh-ed448-cert-v01@openssh.com"; public static final String RSA_SHA2_256_CERT_V01_AT_OPENSSH_DOT_COM = "rsa-sha2-256-cert-v01@openssh.com"; public static final String RSA_SHA2_512_CERT_V01_AT_OPENSSH_DOT_COM = @@ -71,6 +73,7 @@ public static boolean isOpenSshCertificateKeyType(String publicKeyType) { 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 RSA_SHA2_256_CERT_V01_AT_OPENSSH_DOT_COM: case RSA_SHA2_512_CERT_V01_AT_OPENSSH_DOT_COM: return true; diff --git a/src/main/java/com/jcraft/jsch/OpenSshCertificateBuffer.java b/src/main/java/com/jcraft/jsch/OpenSshCertificateBuffer.java index 816bc5a94..7a3429714 100644 --- a/src/main/java/com/jcraft/jsch/OpenSshCertificateBuffer.java +++ b/src/main/java/com/jcraft/jsch/OpenSshCertificateBuffer.java @@ -1,9 +1,11 @@ package com.jcraft.jsch; import java.nio.charset.Charset; +import java.util.ArrayList; import java.util.Collection; import java.util.LinkedHashMap; import java.util.LinkedList; +import java.util.List; import java.util.Map; import static com.jcraft.jsch.OpenSshCertificateUtil.*; @@ -23,7 +25,7 @@ * arrays are prefixed with their length as a 32-bit integer. *

*/ -public class OpenSshCertificateBuffer extends Buffer { +class OpenSshCertificateBuffer extends Buffer { private static final byte[] EMPTY_BYTE_ARRAY = {}; @@ -33,7 +35,7 @@ public class OpenSshCertificateBuffer extends Buffer { * * @param certificateByteDecoded the decoded certificate data */ - public OpenSshCertificateBuffer(byte[] certificateByteDecoded) { + OpenSshCertificateBuffer(byte[] certificateByteDecoded) { super(certificateByteDecoded); s = 0; index = certificateByteDecoded.length; @@ -74,7 +76,7 @@ public String getString(Charset charset) { * @return collection of strings */ public Collection getStrings() { - Collection list = new LinkedList<>(); + List list = new ArrayList<>(); while (getLength() > 0) { String s = getString(UTF_8); list.add(s); @@ -135,15 +137,14 @@ private Map getKeyValueData() { /** * Writes a UTF-8 encoded string to the buffer with length prefix. * - * @param string the strin + * @param string the string */ public void putString(String string) { if (isEmpty(string)) { + putInt(0); putByte(EMPTY_BYTE_ARRAY); } else { - byte[] stringBytes = string.getBytes(UTF_8); - putInt(stringBytes.length); - putByte(stringBytes); + putString(string.getBytes(UTF_8)); } } diff --git a/src/main/java/com/jcraft/jsch/OpenSshCertificateUtil.java b/src/main/java/com/jcraft/jsch/OpenSshCertificateUtil.java index cc0c06ac0..a25b696d7 100644 --- a/src/main/java/com/jcraft/jsch/OpenSshCertificateUtil.java +++ b/src/main/java/com/jcraft/jsch/OpenSshCertificateUtil.java @@ -1,12 +1,11 @@ package com.jcraft.jsch; +import java.time.Instant; import java.util.Date; -import java.util.concurrent.TimeUnit; import static java.nio.charset.StandardCharsets.UTF_8; -import static java.util.concurrent.TimeUnit.MILLISECONDS; -public class OpenSshCertificateUtil { +class OpenSshCertificateUtil { /** @@ -154,7 +153,7 @@ public static String extractKeyType(byte[] s) throws IllegalArgumentException { * otherwise */ static boolean isValidNow(OpenSshCertificate cert) { - long now = MILLISECONDS.toSeconds(System.currentTimeMillis()); + long now = Instant.now().getEpochSecond(); return Long.compareUnsigned(cert.getValidAfter(), now) <= 0 && Long.compareUnsigned(now, cert.getValidBefore()) < 0; } @@ -173,44 +172,31 @@ static String toDateString(long timestamp) { if (timestamp < 0) { return "infinity"; } - Date date = new Date(TimeUnit.SECONDS.toMillis(timestamp)); - return date.toString(); + return SftpATTRS.toDateString(timestamp); } /** * Extracts the raw key type from a given key type string. * - * This method assumes the key type string is in a specific format, such as - * "ssh-rsa-etc-bla-bla@something-cert-something". It splits the string at the "@" character and - * then extracts the substring up to the "-cert" part. It is null-safe and handles empty strings - * gracefully. + * 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. * - * @param keyType The full key type string. - * @return The raw key type (e.g., "ssh-rsa"), or {@code null} if the input is null or empty. + * @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. */ static String getRawKeyType(String keyType) { if (isEmpty(keyType)) { return null; } - - int atIndex = keyType.indexOf("@"); - if (atIndex == -1) { - return null; - } - String prefix = keyType.substring(0, atIndex); - - int certIndex = prefix.indexOf("-cert"); - if (certIndex == -1) { - return null; - } - String subPrefix = prefix.substring(0, certIndex); - - - if (isEmpty(prefix) || isEmpty(subPrefix)) { - return null; + int index = keyType.indexOf("-cert"); + if (index == -1) { + return keyType; // "-cert" not found, return original string } - return subPrefix; + return keyType.substring(0, index); } } diff --git a/src/main/java/com/jcraft/jsch/OpensshCertificateParser.java b/src/main/java/com/jcraft/jsch/OpensshCertificateParser.java index c89a0b609..a4268b471 100644 --- a/src/main/java/com/jcraft/jsch/OpensshCertificateParser.java +++ b/src/main/java/com/jcraft/jsch/OpensshCertificateParser.java @@ -13,6 +13,7 @@ import static com.jcraft.jsch.OpenSshCertificateAwareIdentityFile.RSA_SHA2_512_CERT_V01_AT_OPENSSH_DOT_COM; import static com.jcraft.jsch.OpenSshCertificateAwareIdentityFile.SSH_DSS_CERT_V01_AT_OPENSSH_DOT_COM; import static com.jcraft.jsch.OpenSshCertificateAwareIdentityFile.SSH_ED25519_CERT_V01_AT_OPENSSH_DOT_COM; +import static com.jcraft.jsch.OpenSshCertificateAwareIdentityFile.SSH_ED448_CERT_V01_AT_OPENSSH_DOT_COM; 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; @@ -40,7 +41,7 @@ * @see OpenSSH Certificate * Protocol */ -public class OpensshCertificateParser { +class OpensshCertificateParser { private final String keyType; @@ -174,6 +175,10 @@ private KeyPair parsePublicKey(String keyType, Buffer buffer) throws JSchExcepti buffer.getByte(ed25519_pub_array); return new KeyPairEd25519(instLogger, ed25519_pub_array, null); + case 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); default: throw new JSchException("Unsupported Algorithm for Certificate public key: " + keyType); } From 310b08bb44a6759d9d53f17293a39a7ad54ec2b5 Mon Sep 17 00:00:00 2001 From: Luigi De Masi Date: Mon, 29 Sep 2025 19:29:46 +0200 Subject: [PATCH 3/6] Add support for OpenSSH certificates, resolve #31 - Support for Host Certificate --- src/main/java/com/jcraft/jsch/Buffer.java | 17 + src/main/java/com/jcraft/jsch/DHECN.java | 13 +- src/main/java/com/jcraft/jsch/DHECNKEM.java | 13 +- src/main/java/com/jcraft/jsch/DHGEX.java | 20 +- src/main/java/com/jcraft/jsch/DHGN.java | 12 +- src/main/java/com/jcraft/jsch/DHXEC.java | 13 +- src/main/java/com/jcraft/jsch/DHXECKEM.java | 12 +- src/main/java/com/jcraft/jsch/JSch.java | 7 +- .../JSchInvalidHostCertificateException.java | 10 + .../jsch/JSchUnknownCAKeyException.java | 10 + ...SchUnknownPublicKeyAlgorithmException.java | 10 + .../java/com/jcraft/jsch/KeyExchange.java | 374 +++++++++--------- .../com/jcraft/jsch/OpenSshCertificate.java | 16 + .../OpenSshCertificateAwareIdentityFile.java | 100 +++-- .../jcraft/jsch/OpenSshCertificateBuffer.java | 12 +- .../OpenSshCertificateHostKeyVerifier.java | 222 +++++++++++ ...ser.java => OpenSshCertificateParser.java} | 112 +++--- .../jcraft/jsch/OpenSshCertificateUtil.java | 104 +++++ src/main/java/com/jcraft/jsch/Session.java | 20 +- .../com/jcraft/jsch/SignatureWrapper.java | 189 +++++++++ .../com/jcraft/jsch/HostCertificateIT.java | 216 ++++++++++ .../java/com/jcraft/jsch/KeyExchangeTest.java | 2 +- .../java/com/jcraft/jsch/UserCertAuthIT.java | 110 +++++- .../resources/certificates/docker/Dockerfile | 4 +- .../resources/certificates/host/entrypoint.sh | 5 + .../certificates/host/ssh_host_dsa_key | 21 + .../certificates/host/ssh_host_dsa_key.pub | 1 + .../certificates/host/ssh_host_ecdsa_key | 9 + .../host/ssh_host_ecdsa_key-cert.pub | 1 + .../certificates/host/ssh_host_ecdsa_key.pub | 1 + .../certificates/host/ssh_host_ed25519_key | 7 + .../host/ssh_host_ed25519_key-cert.pub | 1 + .../host/ssh_host_ed25519_key.pub | 1 + .../certificates/host/ssh_host_rsa_key | 38 ++ .../host/ssh_host_rsa_key-cert.pub | 1 + .../certificates/host/ssh_host_rsa_key.pub | 1 + .../resources/certificates/host/sshd_config | 19 + .../host/user_keys/id_ecdsa_nistp521 | 12 + .../host/user_keys/id_ecdsa_nistp521.pub | 1 + src/test/resources/certificates/known_hosts | 1 + 40 files changed, 1396 insertions(+), 342 deletions(-) create mode 100644 src/main/java/com/jcraft/jsch/JSchInvalidHostCertificateException.java create mode 100644 src/main/java/com/jcraft/jsch/JSchUnknownCAKeyException.java create mode 100644 src/main/java/com/jcraft/jsch/JSchUnknownPublicKeyAlgorithmException.java create mode 100644 src/main/java/com/jcraft/jsch/OpenSshCertificateHostKeyVerifier.java rename src/main/java/com/jcraft/jsch/{OpensshCertificateParser.java => OpenSshCertificateParser.java} (65%) create mode 100644 src/main/java/com/jcraft/jsch/SignatureWrapper.java create mode 100644 src/test/java/com/jcraft/jsch/HostCertificateIT.java create mode 100755 src/test/resources/certificates/host/entrypoint.sh create mode 100644 src/test/resources/certificates/host/ssh_host_dsa_key create mode 100644 src/test/resources/certificates/host/ssh_host_dsa_key.pub create mode 100644 src/test/resources/certificates/host/ssh_host_ecdsa_key create mode 100644 src/test/resources/certificates/host/ssh_host_ecdsa_key-cert.pub create mode 100644 src/test/resources/certificates/host/ssh_host_ecdsa_key.pub create mode 100644 src/test/resources/certificates/host/ssh_host_ed25519_key create mode 100644 src/test/resources/certificates/host/ssh_host_ed25519_key-cert.pub create mode 100644 src/test/resources/certificates/host/ssh_host_ed25519_key.pub create mode 100644 src/test/resources/certificates/host/ssh_host_rsa_key create mode 100644 src/test/resources/certificates/host/ssh_host_rsa_key-cert.pub create mode 100644 src/test/resources/certificates/host/ssh_host_rsa_key.pub create mode 100644 src/test/resources/certificates/host/sshd_config create mode 100644 src/test/resources/certificates/host/user_keys/id_ecdsa_nistp521 create mode 100644 src/test/resources/certificates/host/user_keys/id_ecdsa_nistp521.pub create mode 100644 src/test/resources/certificates/known_hosts diff --git a/src/main/java/com/jcraft/jsch/Buffer.java b/src/main/java/com/jcraft/jsch/Buffer.java index 4623555e1..c3fc20b63 100644 --- a/src/main/java/com/jcraft/jsch/Buffer.java +++ b/src/main/java/com/jcraft/jsch/Buffer.java @@ -298,6 +298,23 @@ static Buffer fromBytes(byte[][] args) { return buf; } + /** + * Advances the read position by the specified number of bytes. + * + * If the requested number of bytes would move the position beyond the end of the available data, + * the position is advanced to the very end instead. + * + * @param bytesToSkip the number of bytes to skip. + */ + public void readSkip(int bytesToSkip) { + if (bytesToSkip > getLength()) { + s += getLength(); + return; + } + 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); + i = 0; + + if (isOpenSshCertificateKeyType(alg)) { + this.isOpenSshServerHostKeyType = true; + OpenSshCertificate certificate = OpenSshCertificateParser.parse(session.jsch.instLogger, K_S); + + // Certificates used for host authentication MUST have "certificate role" of + // 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."); } - 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); - } - } else if (alg.equals("ssh-dss")) { - byte[] q = null; - byte[] tmp; - byte[] p; - byte[] g; - byte[] f; - - type = DSS; - key_alg_name = alg; - - 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 c = - Class.forName(session.getConfig("signature.dss")).asSubclass(SignatureDSA.class); - sig = c.getDeclaredConstructor().newInstance(); - sig.init(); - } catch (Exception e) { - throw new JSchException(e.toString(), e); - } - sig.setPubKey(f, p, q, g); - sig.update(H); - result = sig.verify(sig_of_H); + byte[] serverPublicKeyByteArray = certificate.getCertificatePublicKey(); + buffer = new OpenSshCertificateBuffer(serverPublicKeyByteArray); + alg = buffer.getString(UTF_8); + this.hostKeyCertificate = certificate; + } - if (session.getLogger().isEnabled(Logger.INFO)) { - session.getLogger().log(Logger.INFO, "ssh_dss_verify: signature " + result); - } - } 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; - key_alg_name = alg; - - 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 c = - Class.forName(session.getConfig(alg)).asSubclass(SignatureECDSA.class); - sig = c.getDeclaredConstructor().newInstance(); - sig.init(); - } catch (Exception e) { - throw new JSchException(e.toString(), e); - } + switch (alg) { + case "ssh-rsa": + 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 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); - sig.setPubKey(r, s); + 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 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); - sig.update(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 c = + Class.forName(session.getConfig(alg)).asSubclass(SignatureECDSA.class); + sigECDSA = c.getDeclaredConstructor().newInstance(); + sigECDSA.init(); + } catch (Exception e) { + throw new JSchException(e.toString(), e); + } - result = sig.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); - } - } else if (alg.equals("ssh-ed25519") || alg.equals("ssh-ed448")) { - byte[] tmp; - - // RFC 8709, - type = EDDSA; - key_alg_name = alg; - - 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 c = - Class.forName(session.getConfig(alg)).asSubclass(SignatureEdDSA.class); - sig = c.getDeclaredConstructor().newInstance(); - sig.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); + } + 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 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); + } - sig.setPubKey(tmp); + sigEdDSA.setPubKey(edXXX_pub_array); - sig.update(H); + sigEdDSA.update(H); - result = sig.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); - } - } else { - if (session.getLogger().isEnabled(Logger.ERROR)) { - session.getLogger().log(Logger.ERROR, "unknown alg: " + alg); - } + 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; } - - 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 3b228e18c..0f08f6a6e 100644 --- a/src/main/java/com/jcraft/jsch/OpenSshCertificate.java +++ b/src/main/java/com/jcraft/jsch/OpenSshCertificate.java @@ -109,6 +109,11 @@ class OpenSshCertificate { */ private final byte[] signature; + /** + * The certificate data without the certificate type and the signature + */ + private final byte[] message; + /** * Private constructor to be used exclusively by the Builder. */ @@ -127,6 +132,7 @@ private OpenSshCertificate(Builder builder) { this.reserved = builder.reserved; this.signatureKey = builder.signatureKey; this.signature = builder.signature; + this.message = builder.message; } public String getKeyType() { @@ -197,6 +203,10 @@ public boolean isValidNow() { return OpenSshCertificateUtil.isValidNow(this); } + public byte[] getMessage() { + return message; + } + /** * A static inner builder class for creating immutable OpenSshCertificate instances. */ @@ -215,6 +225,7 @@ public static class Builder { private String reserved; private byte[] signatureKey; private byte[] signature; + private byte[] message; public Builder() {} @@ -288,6 +299,11 @@ public Builder signature(byte[] signature) { return this; } + public Builder message(byte[] message) { + this.message = message; + return this; + } + /** * Constructs and returns an immutable OpenSshCertificate instance. * diff --git a/src/main/java/com/jcraft/jsch/OpenSshCertificateAwareIdentityFile.java b/src/main/java/com/jcraft/jsch/OpenSshCertificateAwareIdentityFile.java index da2503f15..dde6a2d40 100644 --- a/src/main/java/com/jcraft/jsch/OpenSshCertificateAwareIdentityFile.java +++ b/src/main/java/com/jcraft/jsch/OpenSshCertificateAwareIdentityFile.java @@ -8,7 +8,9 @@ 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; /** @@ -28,21 +30,31 @@ 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"; + 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"; + public 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 = "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 = "ecdsa-sha2-nistp521-cert-v01@openssh.com"; + public static final String ECDSA_SHA2_NISTP521_CERT = "ecdsa-sha2-nistp521-cert"; + + 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 RSA_SHA2_256_CERT_V01_AT_OPENSSH_DOT_COM = - "rsa-sha2-256-cert-v01@openssh.com"; - public static final String RSA_SHA2_512_CERT_V01_AT_OPENSSH_DOT_COM = - "rsa-sha2-512-cert-v01@openssh.com"; + public static final String SSH_ED448_CERT = "ssh-ed448-cert"; /** @@ -74,30 +86,47 @@ public static boolean isOpenSshCertificateKeyType(String publicKeyType) { 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 RSA_SHA2_256_CERT_V01_AT_OPENSSH_DOT_COM: - case RSA_SHA2_512_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; } } - /** parsed certificate. **/ + /** + * parsed certificate. + **/ private final OpenSshCertificate certificate; - /** the key type declared in the first part of the file **/ + /** + * the key type declared in the first part of the file + **/ private final String keyType; - /** The entire certificate as raw bytes */ + /** + * The entire certificate as raw bytes + */ private final byte[] publicKeyBlob; - /** The key pair containing the private key */ + /** + * The key pair containing the private key + */ private final KeyPair kpair; - /** The identity name/path */ + /** + * The identity name/path + */ private final String identity; - /** Optional comment associated with the identity */ + /** + * Optional comment associated with the identity + */ private final String comment; @@ -113,15 +142,15 @@ public static boolean isOpenSshCertificateKeyType(String publicKeyType) { static Identity newInstance(String prvfile, String pubfile, JSch.InstanceLogger instLogger) throws JSchException { byte[] prvkey; - byte[] pubkey; + byte[] certificateFileContent; try { prvkey = Util.fromFile(prvfile); - pubkey = Util.fromFile(pubfile); + certificateFileContent = Util.fromFile(pubfile); } catch (IOException e) { throw new JSchException(e.toString(), e); } - return newInstance(prvfile, prvkey, pubkey, instLogger); + return newInstance(prvfile, prvkey, certificateFileContent, instLogger); } /** @@ -129,35 +158,53 @@ static Identity newInstance(String prvfile, String pubfile, JSch.InstanceLogger * * @param name the identity name * @param prvkey the private key bytes - * @param pubkey the certificate bytes + * @param certificateFileContentBytes the certificate bytes * @param instLogger logger instance for debugging * @return a new Identity instance * @throws JSchException if the certificate cannot be parsed */ - static Identity newInstance(String name, byte[] prvkey, byte[] pubkey, + static Identity newInstance(String name, byte[] prvkey, byte[] certificateFileContentBytes, JSch.InstanceLogger instLogger) throws JSchException { - String certString = new String(pubkey, UTF_8); + String certificateFileContentString = new String(certificateFileContentBytes, UTF_8); OpenSshCertificate cert; byte[] certPublicKey; KeyPair kpair; - String keyType; + String declaredKeyType; String comment; String base64KeyData; + try { - cert = new OpensshCertificateParser(instLogger, certString).parse(); + declaredKeyType = extractKeyType(certificateFileContentString); + base64KeyData = extractKeyData(certificateFileContentString); + comment = extractComment(certificateFileContentString); + byte[] keyData = + fromBase64(base64KeyData.getBytes(UTF_8), 0, base64KeyData.getBytes(UTF_8).length); + cert = OpenSshCertificateParser.parse(instLogger, keyData); + + // keyType + if (cert.getKeyType().isEmpty() || !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() + "'"); + } + + if (!cert.isValidNow()) { + instLogger.getLogger().log(Logger.WARN, + "certificate is not valid. Valid after: " + toDateString(cert.getValidAfter()) + + " - Valid before: " + toDateString(cert.getValidBefore())); + } + certPublicKey = cert.getCertificatePublicKey(); kpair = KeyPair.load(instLogger, prvkey, certPublicKey); - keyType = extractKeyType(certString); - base64KeyData = extractKeyData(certString); - comment = extractComment(certString); - } catch (IOException | NoSuchAlgorithmException e) { + } catch (Exception e) { throw new JSchException(e.toString(), e); } - return new OpenSshCertificateAwareIdentityFile(name, keyType, base64KeyData, cert, kpair, - comment); + return new OpenSshCertificateAwareIdentityFile(name, declaredKeyType, base64KeyData, cert, + kpair, comment); } /** @@ -228,7 +275,6 @@ public KeyPair getKpair() { return kpair; } - public String getIdentity() { return identity; } diff --git a/src/main/java/com/jcraft/jsch/OpenSshCertificateBuffer.java b/src/main/java/com/jcraft/jsch/OpenSshCertificateBuffer.java index 7a3429714..463a97610 100644 --- a/src/main/java/com/jcraft/jsch/OpenSshCertificateBuffer.java +++ b/src/main/java/com/jcraft/jsch/OpenSshCertificateBuffer.java @@ -4,11 +4,10 @@ import java.util.ArrayList; import java.util.Collection; import java.util.LinkedHashMap; -import java.util.LinkedList; import java.util.List; import java.util.Map; -import static com.jcraft.jsch.OpenSshCertificateUtil.*; +import static com.jcraft.jsch.OpenSshCertificateUtil.isEmpty; import static java.nio.charset.StandardCharsets.UTF_8; /** @@ -147,13 +146,4 @@ public void putString(String string) { putString(string.getBytes(UTF_8)); } } - - - public int getReadPosition() { - return s; - } - - public int getWritePosition() { - return index; - } } diff --git a/src/main/java/com/jcraft/jsch/OpenSshCertificateHostKeyVerifier.java b/src/main/java/com/jcraft/jsch/OpenSshCertificateHostKeyVerifier.java new file mode 100644 index 000000000..8c78e4fbd --- /dev/null +++ b/src/main/java/com/jcraft/jsch/OpenSshCertificateHostKeyVerifier.java @@ -0,0 +1,222 @@ +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. + *

+ * This class provides the logic to authenticate a remote host based on an OpenSSH certificate + * presented during the key exchange process. The verification ensures that the host certificate was + * signed by a trusted Certificate Authority (CA) listed in the user's {@code known_hosts} file. + *

+ * The verification process includes: + *
    + *
  • Checking that the signing CA is trusted for the given host.
  • + *
  • Validating the certificate's type, validity period, and principals (hostnames).
  • + *
  • Cryptographically verifying the certificate's signature.
  • + *
  • Ensuring no unrecognized critical options are present.
  • + *
+ */ +public class OpenSshCertificateHostKeyVerifier { + + /** + * Performs a complete verification of a host's OpenSSH certificate. + *

+ * This is the main entry point for host certificate validation. It orchestrates all the necessary + * checks to ensure the certificate is valid and signed by a trusted Certificate Authority (CA) + * for the connected host. + *

+ * + * @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. + */ + public static void checkHostCertificate(Session session, KeyExchange kex) throws Exception { + OpenSshCertificate certificate = kex.getHostKeyCertificate(); + byte[] caPublicKeyByteArray = certificate.getSignatureKey(); + + String base64CaPublicKey = Base64.getEncoder().encodeToString(caPublicKeyByteArray); + + boolean caFound = getTrustedCAs(session.getHostKeyRepository()).stream() + .anyMatch(trustedCA -> trustedCA.isMatched(session.host) && trustedCA.getKey() != null + && trustedCA.getKey().equals(base64CaPublicKey)); + + if (!caFound) { + throw new JSchUnknownCAKeyException( + "rejected HostKey: Certification Authority not in the known hosts for " + session.host); + } + + Buffer caPublicKeyBuffer = new Buffer(caPublicKeyByteArray); + String caPublicKeyAlgorithm = 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); + } + + if (!certificate.isValidNow()) { + throw new JSchInvalidHostCertificateException( + "rejected HostKey: signature verification failed, " + "certificate expired for id:" + + certificateId); + } + + checkSignature(certificate, caPublicKeyAlgorithm); + + // "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 + + "', allowed principals: " + principals); + } + } + + if (!isEmpty(certificate.getCriticalOptions())) { + // no critical option defined for host keys yet + throw new JSchInvalidHostCertificateException( + "rejected HostKey: unrecognized critical options " + certificate.getCriticalOptions()); + } + } + + /** + * 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 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 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 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 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 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 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 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 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 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 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 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: + *
        + *
      1. Parses the certificate to extract the embedded public key
      2. + *
      3. Validates that the certificate is a host certificate (not a user certificate)
      4. + *
      5. Replaces {@code K_S} with the extracted public key
      6. + *
      7. Extracts the underlying algorithm name from the public key
      8. + *
      9. Stores the certificate for subsequent CA validation
      10. + *
      11. Proceeds with signature verification using the extracted public key
      12. + *
      + *
    • + *
    + * + *

    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: + *

    + *
      + *
    1. Determines the key algorithm (RSA, DSS, ECDSA, or EdDSA)
    2. + *
    3. Parses the algorithm-specific public key components from the SSH wire format
    4. + *
    5. Instantiates the appropriate signature verification class
    6. + *
    7. Verifies that {@code sig_of_H} is a valid signature of the exchange hash {@code H} using + * the public key
    8. + *
    + * + * @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 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 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 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: + *

    + *
      + *
    1. The CA public key exists in the known_hosts file with {@code @cert-authority} marker
    2. + *
    3. The CA entry matches the connecting host's pattern
    4. + *
    5. The CA key has not been revoked (no {@code @revoked} entry for same key)
    6. + *
    7. The certificate was signed by this CA (CA public key matches)
    8. + *
    + * + *

    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 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 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: + *

      + *
    1. Listed in the configured {@code ca_signature_algorithms}
    2. + *
    3. Not in the list of unavailable signature algorithms (as determined by + * {@code CheckSignatures})
    4. + *
    + *

    + * + * @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);