Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 42 additions & 1 deletion src/main/java/com/trilead/ssh2/crypto/CommonDecoder.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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 */

Expand Down
18 changes: 18 additions & 0 deletions src/main/java/com/trilead/ssh2/crypto/OpenSSHKeyDecoder.java
Original file line number Diff line number Diff line change
Expand Up @@ -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 (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);
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");
Expand Down Expand Up @@ -233,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}.
*/
Expand Down
43 changes: 43 additions & 0 deletions src/test/java/com/trilead/ssh2/crypto/OpenSSHKeyDecoderTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,49 @@ 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 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");
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
-----BEGIN OPENSSH PRIVATE KEY-----
b3BlbnNzaC1rZXktdjEAAAAAFmFlczEyOC1nY21Ab3BlbnNzaC5jb20AAAAGYmNyeXB0AA
AAGAAAABBeDj4mmoPN0sABZFBulm9OAAAAGAAAAAEAAAAzAAAAC3NzaC1lZDI1NTE5AAAA
IGexhQGf2haGFlQCMQ9cadBvG11w2PYIs0psaVFnH4xaAAAAkIvkYHUJ0AxHVqH0z+miXM
N58eF8e2r9Y4N823lDZYMPwDi9h52ES//W00BDLjtYn3x5W26Zg1tFz+XII0X6wYrMAMLX
WyxmKm+XXVzbw6UH8WgF5wWCd+sTmyi0aB4paxcNxl8PXvkl8FH8OxXVtI2YDuG6lwa4iG
JVbjIVz0umNm89Grk+W1K7AKLDQW3mbhXXBgGJmGZEU9rtJAWoC5Q=
-----END OPENSSH PRIVATE KEY-----
Original file line number Diff line number Diff line change
@@ -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-----
Loading