From 039b64865a392f889e873d7a0e47bcf16c95d07d Mon Sep 17 00:00:00 2001 From: nindanaoto Date: Sun, 22 Mar 2026 00:14:59 +0000 Subject: [PATCH 1/2] Support aes256-gcm@openssh.com and aes128-gcm@openssh.com for key decryption OpenSSH keys encrypted with AES-GCM ciphers (e.g., via ssh-keygen -Z aes256-gcm@openssh.com) could not be imported, failing with "Cannot decrypt, unknown cipher". This adds GCM support to CommonDecoder using JCE's AES/GCM/NoPadding, and fixes OpenSSHKeyDecoder to read the AEAD authentication tag stored after the private section blob. Fixes connectbot/connectbot#1984 Co-Authored-By: Claude Opus 4.6 (1M context) --- .../trilead/ssh2/crypto/CommonDecoder.java | 43 ++++++++++++++++++- .../ssh2/crypto/OpenSSHKeyDecoder.java | 11 +++++ .../ssh2/crypto/OpenSSHKeyDecoderTest.java | 26 +++++++++++ .../openssh_ed25519_aes128gcm_encrypted | 8 ++++ .../openssh_ed25519_aes256gcm_encrypted | 8 ++++ 5 files changed, 95 insertions(+), 1 deletion(-) create mode 100644 src/test/resources/key-encoder-decoder-tests/openssh_ed25519_aes128gcm_encrypted create mode 100644 src/test/resources/key-encoder-decoder-tests/openssh_ed25519_aes256gcm_encrypted diff --git a/src/main/java/com/trilead/ssh2/crypto/CommonDecoder.java b/src/main/java/com/trilead/ssh2/crypto/CommonDecoder.java index 9d6f2b29..ccda6cbc 100644 --- a/src/main/java/com/trilead/ssh2/crypto/CommonDecoder.java +++ b/src/main/java/com/trilead/ssh2/crypto/CommonDecoder.java @@ -2,10 +2,15 @@ import java.io.IOException; import java.security.DigestException; +import java.security.GeneralSecurityException; import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; import java.util.Locale; +import javax.crypto.Cipher; +import javax.crypto.spec.GCMParameterSpec; +import javax.crypto.spec.SecretKeySpec; + import org.mindrot.jbcrypt.BCrypt; import com.trilead.ssh2.crypto.cipher.AES; @@ -19,11 +24,19 @@ * @author Kenny Root */ class CommonDecoder { + private static final int GCM_TAG_SIZE = 16; + private static final int GCM_NONCE_SIZE = 12; + static byte[] decryptData(byte[] data, byte[] pw, byte[] salt, int rounds, String algo) throws IOException { + String algoLower = algo.toLowerCase(Locale.US); + + if (algoLower.equals("aes128-gcm@openssh.com") || algoLower.equals("aes256-gcm@openssh.com")) { + return decryptDataGcm(data, pw, salt, rounds, algoLower); + } + BlockCipher bc; int keySize; - String algoLower = algo.toLowerCase(Locale.US); if (algoLower.equals("des-ede3-cbc")) { bc = new DESede.CBC(); keySize = 24; @@ -88,6 +101,34 @@ static byte[] decryptData(byte[] data, byte[] pw, byte[] salt, int rounds, Strin } } + private static byte[] decryptDataGcm(byte[] data, byte[] pw, byte[] salt, int rounds, String algo) + throws IOException { + int keySize = algo.equals("aes256-gcm@openssh.com") ? 32 : 16; + + if (rounds <= 0) { + throw new IOException("AES-GCM is only supported for OpenSSH format keys (bcrypt KDF)"); + } + + byte[] key = new byte[keySize]; + byte[] iv = new byte[GCM_NONCE_SIZE]; + byte[] keyAndIV = new byte[key.length + iv.length]; + + new BCrypt().pbkdf(pw, salt, rounds, keyAndIV); + + System.arraycopy(keyAndIV, 0, key, 0, key.length); + System.arraycopy(keyAndIV, key.length, iv, 0, iv.length); + + try { + Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding"); + SecretKeySpec keySpec = new SecretKeySpec(key, "AES"); + GCMParameterSpec gcmSpec = new GCMParameterSpec(GCM_TAG_SIZE * 8, iv); + cipher.init(Cipher.DECRYPT_MODE, keySpec, gcmSpec); + return cipher.doFinal(data); + } catch (GeneralSecurityException e) { + throw new IOException("GCM decryption failed", e); + } + } + private static byte[] removePadding(byte[] buff, int blockSize) throws IOException { /* Removes RFC 1423/PKCS #7 padding */ diff --git a/src/main/java/com/trilead/ssh2/crypto/OpenSSHKeyDecoder.java b/src/main/java/com/trilead/ssh2/crypto/OpenSSHKeyDecoder.java index e79af648..fcbcb266 100644 --- a/src/main/java/com/trilead/ssh2/crypto/OpenSSHKeyDecoder.java +++ b/src/main/java/com/trilead/ssh2/crypto/OpenSSHKeyDecoder.java @@ -115,6 +115,17 @@ public static KeyPair decode(byte[] data, String password) throws IOException { byte[] dataBytes = tr.readByteString(); + // For AEAD ciphers (e.g., aes256-gcm@openssh.com), the authentication + // tag is stored after the private section blob in the key file. + // Read it and append to dataBytes so the cipher can verify it. + if (tr.remain() > 0) { + byte[] authTag = tr.readBytes(tr.remain()); + byte[] combined = new byte[dataBytes.length + authTag.length]; + System.arraycopy(dataBytes, 0, combined, 0, dataBytes.length); + System.arraycopy(authTag, 0, combined, dataBytes.length, authTag.length); + dataBytes = combined; + } + if ("bcrypt".equals(kdfname)) { if (password == null) { throw new IOException("OpenSSH key is encrypted"); diff --git a/src/test/java/com/trilead/ssh2/crypto/OpenSSHKeyDecoderTest.java b/src/test/java/com/trilead/ssh2/crypto/OpenSSHKeyDecoderTest.java index b06d4ca1..d5cefd44 100644 --- a/src/test/java/com/trilead/ssh2/crypto/OpenSSHKeyDecoderTest.java +++ b/src/test/java/com/trilead/ssh2/crypto/OpenSSHKeyDecoderTest.java @@ -138,6 +138,32 @@ public void testDecodeEd25519Encrypted() throws IOException { assertTrue(kp.getPublic() instanceof Ed25519PublicKey); } + @Test + public void testDecodeEd25519Aes256GcmEncrypted() throws IOException { + byte[] data = getKeyData("/key-encoder-decoder-tests/openssh_ed25519_aes256gcm_encrypted"); + KeyPair kp = OpenSSHKeyDecoder.decode(data, "testpassword"); + + assertNotNull(kp); + assertTrue(kp.getPrivate() instanceof Ed25519PrivateKey); + assertTrue(kp.getPublic() instanceof Ed25519PublicKey); + } + + @Test + public void testDecodeEd25519Aes256GcmEncryptedWrongPassword() throws IOException { + byte[] data = getKeyData("/key-encoder-decoder-tests/openssh_ed25519_aes256gcm_encrypted"); + assertThrows(IOException.class, () -> OpenSSHKeyDecoder.decode(data, "wrongpassword")); + } + + @Test + public void testDecodeEd25519Aes128GcmEncrypted() throws IOException { + byte[] data = getKeyData("/key-encoder-decoder-tests/openssh_ed25519_aes128gcm_encrypted"); + KeyPair kp = OpenSSHKeyDecoder.decode(data, "testpassword"); + + assertNotNull(kp); + assertTrue(kp.getPrivate() instanceof Ed25519PrivateKey); + assertTrue(kp.getPublic() instanceof Ed25519PublicKey); + } + @Test public void testIsEncryptedRSA() throws IOException { byte[] dataUnencrypted = getKeyData("/key-encoder-decoder-tests/openssh_rsa_2048"); diff --git a/src/test/resources/key-encoder-decoder-tests/openssh_ed25519_aes128gcm_encrypted b/src/test/resources/key-encoder-decoder-tests/openssh_ed25519_aes128gcm_encrypted new file mode 100644 index 00000000..a97f5838 --- /dev/null +++ b/src/test/resources/key-encoder-decoder-tests/openssh_ed25519_aes128gcm_encrypted @@ -0,0 +1,8 @@ +-----BEGIN OPENSSH PRIVATE KEY----- +b3BlbnNzaC1rZXktdjEAAAAAFmFlczEyOC1nY21Ab3BlbnNzaC5jb20AAAAGYmNyeXB0AA +AAGAAAABBeDj4mmoPN0sABZFBulm9OAAAAGAAAAAEAAAAzAAAAC3NzaC1lZDI1NTE5AAAA +IGexhQGf2haGFlQCMQ9cadBvG11w2PYIs0psaVFnH4xaAAAAkIvkYHUJ0AxHVqH0z+miXM +N58eF8e2r9Y4N823lDZYMPwDi9h52ES//W00BDLjtYn3x5W26Zg1tFz+XII0X6wYrMAMLX +WyxmKm+XXVzbw6UH8WgF5wWCd+sTmyi0aB4paxcNxl8PXvkl8FH8OxXVtI2YDuG6lwa4iG +JVbjIVz0umNm89Grk+W1K7AKLDQW3mbhXXBgGJmGZEU9rtJAWoC5Q= +-----END OPENSSH PRIVATE KEY----- diff --git a/src/test/resources/key-encoder-decoder-tests/openssh_ed25519_aes256gcm_encrypted b/src/test/resources/key-encoder-decoder-tests/openssh_ed25519_aes256gcm_encrypted new file mode 100644 index 00000000..da6eb35e --- /dev/null +++ b/src/test/resources/key-encoder-decoder-tests/openssh_ed25519_aes256gcm_encrypted @@ -0,0 +1,8 @@ +-----BEGIN OPENSSH PRIVATE KEY----- +b3BlbnNzaC1rZXktdjEAAAAAFmFlczI1Ni1nY21Ab3BlbnNzaC5jb20AAAAGYmNyeXB0AA +AAGAAAABA2C8Zi2N+fQw52urHMAvw5AAAAGAAAAAEAAAAzAAAAC3NzaC1lZDI1NTE5AAAA +IFpYLyCEF/D2+04cRZjjUpeAnj5gwTyu/HIzgQ9019XBAAAAkPl9Xc4F3erETblM8TMcOW ++Uw7nAK20TUZkMi2IC2h0MM7e3T+mbLnniu/eNT1h0V7PF8C0e63AH8PSLik17fR3ibOky +FFGr4lD+/k9bUfvb2OcqHr7NbIVXHZ3lQSy0FIag3NUpIE/cx9UkEWdKxlK7atXESUx6Ih +RKLsJR29EQbSsx3FXWJS5LP8AoPUg52uYlO0SeUhvL0L7v684UqFY= +-----END OPENSSH PRIVATE KEY----- From 1a6d1d481ce571f4762679c7cdd726f71035f689 Mon Sep 17 00:00:00 2001 From: nindanaoto Date: Sun, 22 Mar 2026 00:43:01 +0000 Subject: [PATCH 2/2] Only append trailing auth tag for AEAD ciphers in OpenSSHKeyDecoder Guard the trailing-bytes read with an isAeadCipher() check so non-AEAD ciphers (CBC, CTR) are not affected. Add a regression test that appends garbage bytes to a non-AEAD key to verify they are ignored. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../trilead/ssh2/crypto/OpenSSHKeyDecoder.java | 9 ++++++++- .../ssh2/crypto/OpenSSHKeyDecoderTest.java | 17 +++++++++++++++++ 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/trilead/ssh2/crypto/OpenSSHKeyDecoder.java b/src/main/java/com/trilead/ssh2/crypto/OpenSSHKeyDecoder.java index fcbcb266..9b9e1c55 100644 --- a/src/main/java/com/trilead/ssh2/crypto/OpenSSHKeyDecoder.java +++ b/src/main/java/com/trilead/ssh2/crypto/OpenSSHKeyDecoder.java @@ -118,7 +118,7 @@ public static KeyPair decode(byte[] data, String password) throws IOException { // For AEAD ciphers (e.g., aes256-gcm@openssh.com), the authentication // tag is stored after the private section blob in the key file. // Read it and append to dataBytes so the cipher can verify it. - if (tr.remain() > 0) { + if (isAeadCipher(ciphername) && tr.remain() > 0) { byte[] authTag = tr.readBytes(tr.remain()); byte[] combined = new byte[dataBytes.length + authTag.length]; System.arraycopy(dataBytes, 0, combined, 0, dataBytes.length); @@ -244,6 +244,13 @@ public static KeyPair decode(byte[] data, String password) throws IOException { return keyPair; } + private static boolean isAeadCipher(String ciphername) { + String lower = ciphername.toLowerCase(java.util.Locale.US); + return lower.equals("aes128-gcm@openssh.com") + || lower.equals("aes256-gcm@openssh.com") + || lower.equals("chacha20-poly1305@openssh.com"); + } + /** * Generate a {@code KeyPair} given an {@code algorithm} and {@code KeySpec}. */ diff --git a/src/test/java/com/trilead/ssh2/crypto/OpenSSHKeyDecoderTest.java b/src/test/java/com/trilead/ssh2/crypto/OpenSSHKeyDecoderTest.java index d5cefd44..24324c48 100644 --- a/src/test/java/com/trilead/ssh2/crypto/OpenSSHKeyDecoderTest.java +++ b/src/test/java/com/trilead/ssh2/crypto/OpenSSHKeyDecoderTest.java @@ -164,6 +164,23 @@ public void testDecodeEd25519Aes128GcmEncrypted() throws IOException { assertTrue(kp.getPublic() instanceof Ed25519PublicKey); } + @Test + public void testDecodeNonAeadEncryptedWithTrailingBytes() throws IOException { + // Verify that trailing bytes after the private section blob do not + // get appended to the ciphertext for non-AEAD ciphers (aes256-ctr). + // This is the scenario the reviewer flagged: without the ciphername + // check, extra bytes would corrupt decryption for non-AEAD keys. + byte[] data = getKeyData("/key-encoder-decoder-tests/openssh_ed25519_encrypted"); + byte[] dataWithTrailing = new byte[data.length + 16]; + System.arraycopy(data, 0, dataWithTrailing, 0, data.length); + + KeyPair kp = OpenSSHKeyDecoder.decode(dataWithTrailing, "testpassword"); + + assertNotNull(kp); + assertTrue(kp.getPrivate() instanceof Ed25519PrivateKey); + assertTrue(kp.getPublic() instanceof Ed25519PublicKey); + } + @Test public void testIsEncryptedRSA() throws IOException { byte[] dataUnencrypted = getKeyData("/key-encoder-decoder-tests/openssh_rsa_2048");