diff --git a/src/main/java/com/trilead/ssh2/crypto/OpenSSHKeyEncoder.java b/src/main/java/com/trilead/ssh2/crypto/OpenSSHKeyEncoder.java index d045a1f6..7cdb6939 100644 --- a/src/main/java/com/trilead/ssh2/crypto/OpenSSHKeyEncoder.java +++ b/src/main/java/com/trilead/ssh2/crypto/OpenSSHKeyEncoder.java @@ -34,6 +34,7 @@ import java.security.interfaces.ECPrivateKey; import java.security.interfaces.ECPublicKey; import java.security.interfaces.RSAPrivateCrtKey; +import java.security.interfaces.RSAPrivateKey; import java.security.interfaces.RSAPublicKey; import java.security.spec.ECPoint; import java.security.spec.InvalidKeySpecException; @@ -648,6 +649,14 @@ public static String exportOpenSSH(PrivateKey privateKey, PublicKey publicKey, S throws InvalidKeyException { if (privateKey instanceof RSAPrivateCrtKey && publicKey instanceof RSAPublicKey) { return exportOpenSSHRSA((RSAPrivateCrtKey) privateKey, (RSAPublicKey) publicKey, comment, passphrase); + } else if (privateKey instanceof RSAPrivateKey && publicKey instanceof RSAPublicKey) { + // Handle non-CRT RSA keys (e.g., from Conscrypt's OpenSSLRSAPrivateKey) + try { + RSAPrivateCrtKey crtKey = PEMEncoder.convertToRSAPrivateCrtKey((RSAPrivateKey) privateKey); + return exportOpenSSHRSA(crtKey, (RSAPublicKey) publicKey, comment, passphrase); + } catch (InvalidKeySpecException | NoSuchAlgorithmException e) { + throw new InvalidKeyException("Failed to convert RSA key to CRT format", e); + } } else if (privateKey instanceof DSAPrivateKey && publicKey instanceof DSAPublicKey) { return exportOpenSSHDSA((DSAPrivateKey) privateKey, (DSAPublicKey) publicKey, comment, passphrase); } else if (privateKey instanceof ECPrivateKey && publicKey instanceof ECPublicKey) { diff --git a/src/main/java/com/trilead/ssh2/crypto/PEMEncoder.java b/src/main/java/com/trilead/ssh2/crypto/PEMEncoder.java index 81259128..8ac1bc03 100644 --- a/src/main/java/com/trilead/ssh2/crypto/PEMEncoder.java +++ b/src/main/java/com/trilead/ssh2/crypto/PEMEncoder.java @@ -4,6 +4,7 @@ import com.trilead.ssh2.crypto.cipher.BlockCipher; import com.trilead.ssh2.crypto.cipher.DES; import com.trilead.ssh2.crypto.cipher.DESede; +import com.trilead.ssh2.crypto.keys.Ed25519PrivateKey; import com.trilead.ssh2.signature.ECDSASHA2Verify; import java.io.IOException; @@ -15,10 +16,14 @@ import java.security.NoSuchAlgorithmException; import java.security.PrivateKey; import java.security.SecureRandom; +import java.security.KeyFactory; import java.security.interfaces.DSAPrivateKey; import java.security.interfaces.ECPrivateKey; import java.security.interfaces.RSAPrivateCrtKey; +import java.security.interfaces.RSAPrivateKey; import java.security.spec.ECFieldFp; +import java.security.spec.InvalidKeySpecException; +import java.security.spec.RSAPrivateCrtKeySpec; import java.security.spec.ECParameterSpec; import java.security.spec.ECPoint; import java.util.Locale; @@ -203,7 +208,7 @@ public static String encodePrivateKey(PrivateKey privateKey, String password) /** * Encode private key to PEM format with auto-detected key type and specified algorithm. * - * @param privateKey private key (RSA, DSA, or EC) + * @param privateKey private key (RSA, DSA, EC, or Ed25519) * @param password password for encryption, or null for unencrypted * @param algorithm encryption algorithm * @return PEM-encoded private key @@ -214,15 +219,100 @@ public static String encodePrivateKey(PrivateKey privateKey, String password, St throws IOException, InvalidKeyException { if (privateKey instanceof RSAPrivateCrtKey) { return encodeRSAPrivateKey((RSAPrivateCrtKey) privateKey, password, algorithm); + } else if (privateKey instanceof RSAPrivateKey) { + // Handle non-CRT RSA keys (e.g., from Conscrypt's OpenSSLRSAPrivateKey) + try { + RSAPrivateCrtKey crtKey = convertToRSAPrivateCrtKey((RSAPrivateKey) privateKey); + return encodeRSAPrivateKey(crtKey, password, algorithm); + } catch (InvalidKeySpecException | NoSuchAlgorithmException e) { + throw new InvalidKeyException("Failed to convert RSA key to CRT format", e); + } } else if (privateKey instanceof DSAPrivateKey) { return encodeDSAPrivateKey((DSAPrivateKey) privateKey, password, algorithm); } else if (privateKey instanceof ECPrivateKey) { return encodeECPrivateKey((ECPrivateKey) privateKey, password, algorithm); + } else if (privateKey instanceof Ed25519PrivateKey) { + return encodeEd25519PrivateKey((Ed25519PrivateKey) privateKey, password, algorithm); } else { throw new InvalidKeyException("Unsupported key type: " + privateKey.getClass().getName()); } } + /** + * Encode Ed25519 private key to PEM format (PKCS#8). + * + * @param privateKey Ed25519 private key + * @param password password for encryption, or null for unencrypted + * @return PEM-encoded private key + * @throws IOException if encoding fails + */ + public static String encodeEd25519PrivateKey(Ed25519PrivateKey privateKey, String password) throws IOException { + return encodeEd25519PrivateKey(privateKey, password, DEFAULT_ENCRYPTION); + } + + /** + * Encode Ed25519 private key to PEM format (PKCS#8) with specified encryption algorithm. + * + * @param privateKey Ed25519 private key + * @param password password for encryption, or null for unencrypted + * @param algorithm encryption algorithm + * @return PEM-encoded private key + * @throws IOException if encoding fails + */ + public static String encodeEd25519PrivateKey(Ed25519PrivateKey privateKey, String password, String algorithm) + throws IOException { + // Ed25519 uses PKCS#8 format (BEGIN PRIVATE KEY) + byte[] encoded = privateKey.getEncoded(); + return formatPEM("PRIVATE KEY", encoded, password, algorithm); + } + + /** + * Converts an RSAPrivateKey to RSAPrivateCrtKey by parsing the PKCS#8 encoded form. + * This is needed for keys from providers like Conscrypt that don't implement RSAPrivateCrtKey. + * + * @param privateKey The RSA private key to convert + * @return The RSAPrivateCrtKey with CRT parameters + * @throws InvalidKeySpecException if the key cannot be parsed + * @throws NoSuchAlgorithmException if RSA algorithm is not available + */ + static RSAPrivateCrtKey convertToRSAPrivateCrtKey(RSAPrivateKey privateKey) + throws InvalidKeySpecException, NoSuchAlgorithmException { + byte[] encoded = privateKey.getEncoded(); + try { + SimpleDERReader reader = new SimpleDERReader(encoded); + reader.resetInput(reader.readSequenceAsByteArray()); + if (!reader.readInt().equals(BigInteger.ZERO)) { + throw new InvalidKeySpecException("PKCS#8 is not version 0"); + } + + reader.readSequenceAsByteArray(); // OID sequence + reader.resetInput(reader.readOctetString()); // RSA key bytes + reader.resetInput(reader.readSequenceAsByteArray()); // RSA key sequence + + if (!reader.readInt().equals(BigInteger.ZERO)) { + throw new InvalidKeySpecException("RSA key is not version 0"); + } + + BigInteger modulus = reader.readInt(); + BigInteger publicExponent = reader.readInt(); + BigInteger privateExponent = reader.readInt(); + BigInteger primeP = reader.readInt(); + BigInteger primeQ = reader.readInt(); + BigInteger primeExponentP = reader.readInt(); + BigInteger primeExponentQ = reader.readInt(); + BigInteger crtCoefficient = reader.readInt(); + + RSAPrivateCrtKeySpec spec = new RSAPrivateCrtKeySpec( + modulus, publicExponent, privateExponent, + primeP, primeQ, primeExponentP, primeExponentQ, crtCoefficient); + + KeyFactory kf = KeyFactory.getInstance("RSA"); + return (RSAPrivateCrtKey) kf.generatePrivate(spec); + } catch (IOException e) { + throw new InvalidKeySpecException("Could not parse RSA key", e); + } + } + private static String formatPEM(String type, byte[] data, String password, String algorithm) throws IOException { byte[] encodedData = data; diff --git a/src/test/java/com/trilead/ssh2/crypto/OpenSSHKeyEncoderTest.java b/src/test/java/com/trilead/ssh2/crypto/OpenSSHKeyEncoderTest.java index 0bdfc08f..e699b3db 100644 --- a/src/test/java/com/trilead/ssh2/crypto/OpenSSHKeyEncoderTest.java +++ b/src/test/java/com/trilead/ssh2/crypto/OpenSSHKeyEncoderTest.java @@ -5,6 +5,7 @@ import static org.junit.jupiter.api.Assertions.assertTrue; import java.io.IOException; +import java.math.BigInteger; import java.security.KeyPair; import java.security.KeyPairGenerator; import java.security.interfaces.DSAPrivateKey; @@ -12,6 +13,7 @@ import java.security.interfaces.ECPrivateKey; import java.security.interfaces.ECPublicKey; import java.security.interfaces.RSAPrivateCrtKey; +import java.security.interfaces.RSAPrivateKey; import java.security.interfaces.RSAPublicKey; import java.security.spec.ECGenParameterSpec; @@ -309,4 +311,101 @@ public void testDecodeGoldenEd25519AndReencode() throws Exception { assertEquals(kp.getPublic(), decoded.getPublic()); assertEquals(kp.getPrivate(), decoded.getPrivate()); } + + /** + * Tests that non-CRT RSA keys (like Conscrypt's OpenSSLRSAPrivateKey) can be exported. + * This simulates the scenario where an RSAPrivateKey does not implement RSAPrivateCrtKey. + */ + @Test + public void testExportOpenSSHWithNonCrtRSAKey() throws Exception { + KeyPairGenerator kpg = KeyPairGenerator.getInstance("RSA"); + kpg.initialize(2048); + KeyPair original = kpg.generateKeyPair(); + + // Wrap the RSA private key to simulate a non-CRT key (like OpenSSLRSAPrivateKey) + RSAPrivateKey nonCrtKey = new NonCrtRSAPrivateKeyWrapper((RSAPrivateCrtKey) original.getPrivate()); + + // Verify our wrapper is not an instance of RSAPrivateCrtKey + assertTrue(nonCrtKey instanceof RSAPrivateKey); + assertTrue(!(nonCrtKey instanceof RSAPrivateCrtKey)); + + // Export using the generic method which should handle non-CRT keys + String exported = OpenSSHKeyEncoder.exportOpenSSH( + nonCrtKey, + original.getPublic(), + "test-non-crt"); + + assertNotNull(exported); + assertTrue(exported.contains("-----BEGIN OPENSSH PRIVATE KEY-----")); + assertTrue(exported.contains("-----END OPENSSH PRIVATE KEY-----")); + + // Verify round-trip + KeyPair decoded = PEMDecoder.decode(exported.toCharArray(), null); + + assertEquals(original.getPublic(), decoded.getPublic()); + assertEquals(original.getPrivate(), decoded.getPrivate()); + } + + /** + * Tests that non-CRT RSA keys can be exported with encryption. + */ + @Test + public void testExportOpenSSHWithNonCrtRSAKeyEncrypted() throws Exception { + KeyPairGenerator kpg = KeyPairGenerator.getInstance("RSA"); + kpg.initialize(2048); + KeyPair original = kpg.generateKeyPair(); + + RSAPrivateKey nonCrtKey = new NonCrtRSAPrivateKeyWrapper((RSAPrivateCrtKey) original.getPrivate()); + + String exported = OpenSSHKeyEncoder.exportOpenSSH( + nonCrtKey, + original.getPublic(), + "test-non-crt", + "testpassword"); + + assertNotNull(exported); + assertTrue(exported.contains("-----BEGIN OPENSSH PRIVATE KEY-----")); + + KeyPair decoded = PEMDecoder.decode(exported.toCharArray(), "testpassword"); + + assertEquals(original.getPublic(), decoded.getPublic()); + assertEquals(original.getPrivate(), decoded.getPrivate()); + } + + /** + * A wrapper that implements RSAPrivateKey but NOT RSAPrivateCrtKey. + * This simulates keys from providers like Conscrypt's OpenSSLRSAPrivateKey. + */ + private static class NonCrtRSAPrivateKeyWrapper implements RSAPrivateKey { + private final RSAPrivateCrtKey delegate; + + NonCrtRSAPrivateKeyWrapper(RSAPrivateCrtKey delegate) { + this.delegate = delegate; + } + + @Override + public BigInteger getPrivateExponent() { + return delegate.getPrivateExponent(); + } + + @Override + public String getAlgorithm() { + return delegate.getAlgorithm(); + } + + @Override + public String getFormat() { + return delegate.getFormat(); + } + + @Override + public byte[] getEncoded() { + return delegate.getEncoded(); + } + + @Override + public BigInteger getModulus() { + return delegate.getModulus(); + } + } } diff --git a/src/test/java/com/trilead/ssh2/crypto/PEMEncoderTest.java b/src/test/java/com/trilead/ssh2/crypto/PEMEncoderTest.java index 221760e4..9a62e810 100644 --- a/src/test/java/com/trilead/ssh2/crypto/PEMEncoderTest.java +++ b/src/test/java/com/trilead/ssh2/crypto/PEMEncoderTest.java @@ -2,12 +2,19 @@ import org.junit.jupiter.api.Test; +import com.google.crypto.tink.subtle.Ed25519Sign; +import com.trilead.ssh2.crypto.keys.Ed25519PrivateKey; + +import java.math.BigInteger; import java.nio.file.Files; import java.nio.file.Paths; import java.security.KeyPair; import java.security.interfaces.DSAPrivateKey; import java.security.interfaces.ECPrivateKey; import java.security.interfaces.RSAPrivateCrtKey; +import java.security.interfaces.RSAPrivateKey; +import java.security.spec.PKCS8EncodedKeySpec; +import java.util.Base64; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; @@ -226,4 +233,149 @@ private void assertKeysEqual(KeyPair expected, KeyPair actual) { byte[] actualPub = actual.getPublic().getEncoded(); assertEquals(expectedPub.length, actualPub.length, "Public key encoded length should match"); } + + @Test + void testEncodeEd25519Unencrypted() throws Exception { + Ed25519Sign.KeyPair tinkKeyPair = Ed25519Sign.KeyPair.newKeyPair(); + Ed25519PrivateKey privateKey = new Ed25519PrivateKey(tinkKeyPair.getPrivateKey()); + + String encoded = PEMEncoder.encodeEd25519PrivateKey(privateKey, null); + + assertNotNull(encoded); + assertTrue(encoded.contains("-----BEGIN PRIVATE KEY-----")); + assertTrue(encoded.contains("-----END PRIVATE KEY-----")); + + // Verify round-trip by manually decoding PKCS#8 format + Ed25519PrivateKey decoded = decodePkcs8Ed25519(encoded); + assertNotNull(decoded); + assertEquals(privateKey, decoded); + } + + @Test + void testEncodeEd25519WithAES256() throws Exception { + Ed25519Sign.KeyPair tinkKeyPair = Ed25519Sign.KeyPair.newKeyPair(); + Ed25519PrivateKey privateKey = new Ed25519PrivateKey(tinkKeyPair.getPrivateKey()); + + String encoded = PEMEncoder.encodeEd25519PrivateKey(privateKey, TEST_PASSWORD, PEMEncoder.AES_256_CBC); + + assertNotNull(encoded); + assertTrue(encoded.contains("-----BEGIN PRIVATE KEY-----")); + assertTrue(encoded.contains("Proc-Type: 4,ENCRYPTED")); + assertTrue(encoded.contains("DEK-Info: AES-256-CBC")); + + // Encryption test: verify the format is correct (decryption would require + // implementing the same key derivation as PEMDecoder, which is not needed + // since OpenSSH format is preferred for encrypted Ed25519 keys) + } + + @Test + void testEncodePrivateKeyAutoDetectEd25519() throws Exception { + Ed25519Sign.KeyPair tinkKeyPair = Ed25519Sign.KeyPair.newKeyPair(); + Ed25519PrivateKey privateKey = new Ed25519PrivateKey(tinkKeyPair.getPrivateKey()); + + String encoded = PEMEncoder.encodePrivateKey(privateKey, null); + + assertNotNull(encoded); + assertTrue(encoded.contains("-----BEGIN PRIVATE KEY-----")); + + Ed25519PrivateKey decoded = decodePkcs8Ed25519(encoded); + assertNotNull(decoded); + assertEquals(privateKey, decoded); + } + + /** + * Decodes an Ed25519 private key from PKCS#8 PEM format. + */ + private Ed25519PrivateKey decodePkcs8Ed25519(String pem) throws Exception { + String base64 = pem + .replace("-----BEGIN PRIVATE KEY-----", "") + .replace("-----END PRIVATE KEY-----", "") + .replaceAll("\\s", ""); + byte[] decoded = Base64.getDecoder().decode(base64); + return new Ed25519PrivateKey(new PKCS8EncodedKeySpec(decoded)); + } + + /** + * Tests that non-CRT RSA keys (like Conscrypt's OpenSSLRSAPrivateKey) can be encoded. + * This simulates the scenario where an RSAPrivateKey does not implement RSAPrivateCrtKey. + */ + @Test + void testEncodeNonCrtRSAKeyUnencrypted() throws Exception { + KeyPair original = loadKeyPair("pem_rsa_2048", null); + + // Wrap the RSA private key to simulate a non-CRT key (like OpenSSLRSAPrivateKey) + RSAPrivateKey nonCrtKey = new NonCrtRSAPrivateKeyWrapper((RSAPrivateCrtKey) original.getPrivate()); + + // Verify our wrapper is not an instance of RSAPrivateCrtKey + assertTrue(nonCrtKey instanceof RSAPrivateKey); + assertTrue(!(nonCrtKey instanceof RSAPrivateCrtKey)); + + // Encode using the generic method which should handle non-CRT keys + String encoded = PEMEncoder.encodePrivateKey(nonCrtKey, null); + + assertNotNull(encoded); + assertTrue(encoded.contains("-----BEGIN RSA PRIVATE KEY-----")); + assertTrue(encoded.contains("-----END RSA PRIVATE KEY-----")); + + // Verify round-trip + KeyPair decoded = PEMDecoder.decode(encoded.toCharArray(), null); + assertKeysEqual(original, decoded); + } + + /** + * Tests that non-CRT RSA keys can be encoded with encryption. + */ + @Test + void testEncodeNonCrtRSAKeyWithAES256() throws Exception { + KeyPair original = loadKeyPair("pem_rsa_2048", null); + + RSAPrivateKey nonCrtKey = new NonCrtRSAPrivateKeyWrapper((RSAPrivateCrtKey) original.getPrivate()); + + String encoded = PEMEncoder.encodePrivateKey(nonCrtKey, TEST_PASSWORD, PEMEncoder.AES_256_CBC); + + assertNotNull(encoded); + assertTrue(encoded.contains("-----BEGIN RSA PRIVATE KEY-----")); + assertTrue(encoded.contains("Proc-Type: 4,ENCRYPTED")); + assertTrue(encoded.contains("DEK-Info: AES-256-CBC")); + + KeyPair decoded = PEMDecoder.decode(encoded.toCharArray(), TEST_PASSWORD); + assertKeysEqual(original, decoded); + } + + /** + * A wrapper that implements RSAPrivateKey but NOT RSAPrivateCrtKey. + * This simulates keys from providers like Conscrypt's OpenSSLRSAPrivateKey. + */ + private static class NonCrtRSAPrivateKeyWrapper implements RSAPrivateKey { + private final RSAPrivateCrtKey delegate; + + NonCrtRSAPrivateKeyWrapper(RSAPrivateCrtKey delegate) { + this.delegate = delegate; + } + + @Override + public BigInteger getPrivateExponent() { + return delegate.getPrivateExponent(); + } + + @Override + public String getAlgorithm() { + return delegate.getAlgorithm(); + } + + @Override + public String getFormat() { + return delegate.getFormat(); + } + + @Override + public byte[] getEncoded() { + return delegate.getEncoded(); + } + + @Override + public BigInteger getModulus() { + return delegate.getModulus(); + } + } }