Skip to content

Commit 9b9b208

Browse files
Add AES-GCM Support for OpenSSH Private Keys (hierynomus#899)
* Added AES-GCM Support for OpenSSH Private Keys * Small simplification Signed-off-by: Jeroen van Erp <jeroen@hierynomus.com> --------- Signed-off-by: Jeroen van Erp <jeroen@hierynomus.com> Co-authored-by: Jeroen van Erp <jeroen@hierynomus.com>
1 parent a3cce0d commit 9b9b208

File tree

4 files changed

+114
-35
lines changed

4 files changed

+114
-35
lines changed

src/main/java/com/hierynomus/sshj/userauth/keyprovider/OpenSSHKeyV1KeyFile.java

Lines changed: 98 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
import com.hierynomus.sshj.common.KeyAlgorithm;
1919
import com.hierynomus.sshj.common.KeyDecryptionFailedException;
2020
import com.hierynomus.sshj.transport.cipher.BlockCiphers;
21+
import com.hierynomus.sshj.transport.cipher.GcmCiphers;
2122
import net.i2p.crypto.eddsa.EdDSAPrivateKey;
2223
import net.i2p.crypto.eddsa.spec.EdDSANamedCurveTable;
2324
import net.i2p.crypto.eddsa.spec.EdDSAPrivateKeySpec;
@@ -36,6 +37,7 @@
3637
import org.bouncycastle.asn1.x9.X9ECParameters;
3738
import org.bouncycastle.jce.spec.ECNamedCurveSpec;
3839
import com.hierynomus.sshj.userauth.keyprovider.bcrypt.BCrypt;
40+
import org.bouncycastle.openssl.EncryptionException;
3941
import org.slf4j.Logger;
4042
import org.slf4j.LoggerFactory;
4143

@@ -47,24 +49,42 @@
4749
import java.math.BigInteger;
4850
import java.nio.ByteBuffer;
4951
import java.nio.CharBuffer;
50-
import java.nio.charset.Charset;
52+
import java.nio.charset.StandardCharsets;
5153
import java.security.*;
5254
import java.security.spec.ECPrivateKeySpec;
5355
import java.security.spec.RSAPrivateCrtKeySpec;
5456
import java.util.Arrays;
5557
import java.util.Base64;
58+
import java.util.HashMap;
59+
import java.util.Map;
5660

5761
/**
5862
* Reads a key file in the new OpenSSH format.
5963
* The format is described in the following document: https://github.com/openssh/openssh-portable/blob/master/PROTOCOL.key
6064
*/
6165
public class OpenSSHKeyV1KeyFile extends BaseFileKeyProvider {
62-
private static final Logger logger = LoggerFactory.getLogger(OpenSSHKeyV1KeyFile.class);
6366
private static final String BEGIN = "-----BEGIN ";
6467
private static final String END = "-----END ";
6568
private static final byte[] AUTH_MAGIC = "openssh-key-v1\0".getBytes();
6669
public static final String OPENSSH_PRIVATE_KEY = "OPENSSH PRIVATE KEY-----";
6770
public static final String BCRYPT = "bcrypt";
71+
72+
private static final String NONE_CIPHER = "none";
73+
74+
private static final Map<String, Factory.Named<Cipher>> SUPPORTED_CIPHERS = new HashMap<>();
75+
76+
static {
77+
SUPPORTED_CIPHERS.put(BlockCiphers.TripleDESCBC().getName(), BlockCiphers.TripleDESCBC());
78+
SUPPORTED_CIPHERS.put(BlockCiphers.AES128CBC().getName(), BlockCiphers.AES128CBC());
79+
SUPPORTED_CIPHERS.put(BlockCiphers.AES192CBC().getName(), BlockCiphers.AES192CBC());
80+
SUPPORTED_CIPHERS.put(BlockCiphers.AES256CBC().getName(), BlockCiphers.AES256CBC());
81+
SUPPORTED_CIPHERS.put(BlockCiphers.AES128CTR().getName(), BlockCiphers.AES128CTR());
82+
SUPPORTED_CIPHERS.put(BlockCiphers.AES192CTR().getName(), BlockCiphers.AES192CTR());
83+
SUPPORTED_CIPHERS.put(BlockCiphers.AES256CTR().getName(), BlockCiphers.AES256CTR());
84+
SUPPORTED_CIPHERS.put(GcmCiphers.AES256GCM().getName(), GcmCiphers.AES256GCM());
85+
SUPPORTED_CIPHERS.put(GcmCiphers.AES128GCM().getName(), GcmCiphers.AES128GCM());
86+
}
87+
6888
private PublicKey pubKey;
6989

7090
public static class Factory
@@ -135,74 +155,117 @@ private KeyPair readDecodedKeyPair(final PlainBuffer keyBuffer) throws IOExcepti
135155

136156
int nrKeys = keyBuffer.readUInt32AsInt(); // int number of keys N; Should be 1
137157
if (nrKeys != 1) {
138-
throw new IOException("We don't support having more than 1 key in the file (yet).");
158+
final String message = String.format("OpenSSH Private Key number of keys not supported [%d]", nrKeys);
159+
throw new IOException(message);
139160
}
140161
PublicKey publicKey = pubKey;
141162
if (publicKey == null) {
142163
publicKey = readPublicKey(new PlainBuffer(keyBuffer.readBytes()));
143-
}
144-
else {
164+
} else {
145165
keyBuffer.readBytes();
146166
}
147-
PlainBuffer privateKeyBuffer = new PlainBuffer(keyBuffer.readBytes()); // string (possibly) encrypted, padded list of private keys
148-
if ("none".equals(cipherName)) {
149-
logger.debug("Reading unencrypted keypair");
167+
168+
final byte[] privateKeyEncoded = keyBuffer.readBytes();
169+
final PlainBuffer privateKeyBuffer = new PlainBuffer(privateKeyEncoded);
170+
171+
if (NONE_CIPHER.equals(cipherName)) {
150172
return readUnencrypted(privateKeyBuffer, publicKey);
151173
} else {
152-
logger.info("Keypair is encrypted with: {}, {}, {}", cipherName, kdfName, Arrays.toString(kdfOptions));
174+
final byte[] encryptedPrivateKey = readEncryptedPrivateKey(privateKeyEncoded, keyBuffer);
153175
while (true) {
154-
PlainBuffer decryptionBuffer = new PlainBuffer(privateKeyBuffer);
155-
PlainBuffer decrypted = decryptBuffer(decryptionBuffer, cipherName, kdfName, kdfOptions);
176+
final byte[] encrypted = encryptedPrivateKey.clone();
156177
try {
178+
final PlainBuffer decrypted = decryptPrivateKey(encrypted, privateKeyEncoded.length, cipherName, kdfName, kdfOptions);
157179
return readUnencrypted(decrypted, publicKey);
158180
} catch (KeyDecryptionFailedException e) {
159181
if (pwdf == null || !pwdf.shouldRetry(resource))
160182
throw e;
161183
}
162184
}
163-
// throw new IOException("Cannot read encrypted keypair with " + cipherName + " yet.");
164185
}
165186
}
166187

167-
private PlainBuffer decryptBuffer(PlainBuffer privateKeyBuffer, String cipherName, String kdfName, byte[] kdfOptions) throws IOException {
168-
Cipher cipher = createCipher(cipherName);
169-
initializeCipher(kdfName, kdfOptions, cipher);
170-
byte[] array = privateKeyBuffer.array();
171-
cipher.update(array, 0, privateKeyBuffer.available());
172-
return new PlainBuffer(array);
188+
private byte[] readEncryptedPrivateKey(final byte[] privateKeyEncoded, final PlainBuffer inputBuffer) throws Buffer.BufferException {
189+
final byte[] encryptedPrivateKey;
190+
191+
final int bufferRemaining = inputBuffer.available();
192+
if (bufferRemaining == 0) {
193+
encryptedPrivateKey = privateKeyEncoded;
194+
} else {
195+
// Read Authentication Tag for AES-GCM
196+
final byte[] authenticationTag = new byte[bufferRemaining];
197+
inputBuffer.readRawBytes(authenticationTag);
198+
199+
final int encryptedBufferLength = privateKeyEncoded.length + authenticationTag.length;
200+
final PlainBuffer encryptedBuffer = new PlainBuffer(encryptedBufferLength);
201+
encryptedBuffer.putRawBytes(privateKeyEncoded);
202+
encryptedBuffer.putRawBytes(authenticationTag);
203+
204+
encryptedPrivateKey = new byte[encryptedBufferLength];
205+
encryptedBuffer.readRawBytes(encryptedPrivateKey);
206+
}
207+
208+
return encryptedPrivateKey;
173209
}
174210

175-
private void initializeCipher(String kdfName, byte[] kdfOptions, Cipher cipher) throws Buffer.BufferException {
211+
private PlainBuffer decryptPrivateKey(final byte[] privateKey, final int privateKeyLength, final String cipherName, final String kdfName, final byte[] kdfOptions) throws IOException {
212+
try {
213+
final Cipher cipher = createCipher(cipherName);
214+
initializeCipher(kdfName, kdfOptions, cipher);
215+
cipher.update(privateKey, 0, privateKeyLength);
216+
} catch (final SSHRuntimeException e) {
217+
final String message = String.format("OpenSSH Private Key decryption failed with cipher [%s]", cipherName);
218+
throw new KeyDecryptionFailedException(new EncryptionException(message, e));
219+
}
220+
final PlainBuffer decryptedPrivateKey = new PlainBuffer(privateKeyLength);
221+
decryptedPrivateKey.putRawBytes(privateKey, 0, privateKeyLength);
222+
return decryptedPrivateKey;
223+
}
224+
225+
private void initializeCipher(final String kdfName, final byte[] kdfOptions, final Cipher cipher) throws Buffer.BufferException {
176226
if (kdfName.equals(BCRYPT)) {
177-
PlainBuffer opts = new PlainBuffer(kdfOptions);
227+
final PlainBuffer bufferedOptions = new PlainBuffer(kdfOptions);
178228
byte[] passphrase = new byte[0];
179229
if (pwdf != null) {
180-
CharBuffer charBuffer = CharBuffer.wrap(pwdf.reqPassword(null));
181-
ByteBuffer byteBuffer = Charset.forName("UTF-8").encode(charBuffer);
230+
final CharBuffer charBuffer = CharBuffer.wrap(pwdf.reqPassword(null));
231+
final ByteBuffer byteBuffer = StandardCharsets.UTF_8.encode(charBuffer);
182232
passphrase = Arrays.copyOfRange(byteBuffer.array(), byteBuffer.position(), byteBuffer.limit());
183233
Arrays.fill(charBuffer.array(), '\u0000');
184234
Arrays.fill(byteBuffer.array(), (byte) 0);
185235
}
186-
byte[] keyiv = new byte[cipher.getIVSize()+ cipher.getBlockSize()];
187-
new BCrypt().pbkdf(passphrase, opts.readBytes(), opts.readUInt32AsInt(), keyiv);
236+
237+
final int ivSize = cipher.getIVSize();
238+
final int blockSize = cipher.getBlockSize();
239+
final int parameterSize = ivSize + blockSize;
240+
final byte[] keyIvParameters = new byte[parameterSize];
241+
242+
final byte[] salt = bufferedOptions.readBytes();
243+
final int iterations = bufferedOptions.readUInt32AsInt();
244+
new BCrypt().pbkdf(passphrase, salt, iterations, keyIvParameters);
188245
Arrays.fill(passphrase, (byte) 0);
189-
byte[] key = Arrays.copyOfRange(keyiv, 0, cipher.getBlockSize());
190-
byte[] iv = Arrays.copyOfRange(keyiv, cipher.getBlockSize(), cipher.getIVSize() + cipher.getBlockSize());
246+
247+
final byte[] key = Arrays.copyOfRange(keyIvParameters, 0, blockSize);
248+
final byte[] iv = Arrays.copyOfRange(keyIvParameters, blockSize, parameterSize);
249+
191250
cipher.init(Cipher.Mode.Decrypt, key, iv);
192251
} else {
193-
throw new IllegalStateException("No support for KDF '" + kdfName + "'.");
252+
final String message = String.format("OpenSSH Private Key encryption KDF not supported [%s]", kdfName);
253+
throw new IllegalStateException(message);
194254
}
195255
}
196256

197-
private Cipher createCipher(String cipherName) {
198-
if (cipherName.equals(BlockCiphers.AES256CTR().getName())) {
199-
return BlockCiphers.AES256CTR().create();
200-
} else if (cipherName.equals(BlockCiphers.AES256CBC().getName())) {
201-
return BlockCiphers.AES256CBC().create();
202-
} else if (cipherName.equals(BlockCiphers.AES128CBC().getName())) {
203-
return BlockCiphers.AES128CBC().create();
257+
private Cipher createCipher(final String cipherName) {
258+
final Cipher cipher;
259+
260+
if (SUPPORTED_CIPHERS.containsKey(cipherName)) {
261+
final Factory.Named<Cipher> cipherFactory = SUPPORTED_CIPHERS.get(cipherName);
262+
cipher = cipherFactory.create();
263+
} else {
264+
final String message = String.format("OpenSSH Key encryption cipher not supported [%s]", cipherName);
265+
throw new IllegalStateException(message);
204266
}
205-
throw new IllegalStateException("Cipher '" + cipherName + "' not currently implemented for openssh-key-v1 format");
267+
268+
return cipher;
206269
}
207270

208271
private PublicKey readPublicKey(final PlainBuffer plainBuffer) throws Buffer.BufferException, GeneralSecurityException {

src/test/java/net/schmizz/sshj/keyprovider/OpenSSHKeyFileTest.java

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -259,6 +259,12 @@ public void shouldLoadProtectedED25519PrivateKeyAes128CBC() throws IOException {
259259
checkOpenSSHKeyV1("src/test/resources/keytypes/ed25519_aes128cbc.pem", "sshjtest", true);
260260
}
261261

262+
@Test
263+
public void shouldLoadProtectedEd25519PrivateKeyAES256GCM() throws IOException {
264+
checkOpenSSHKeyV1("src/test/resources/keytypes/ed25519_aes256-gcm", "sshjtest", false);
265+
checkOpenSSHKeyV1("src/test/resources/keytypes/ed25519_aes256-gcm", "sshjtest", true);
266+
}
267+
262268
@Test
263269
public void shouldFailOnIncorrectPassphraseAfterRetries() {
264270
assertThrows(KeyDecryptionFailedException.class, () -> {
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
-----BEGIN OPENSSH PRIVATE KEY-----
2+
b3BlbnNzaC1rZXktdjEAAAAAFmFlczI1Ni1nY21Ab3BlbnNzaC5jb20AAAAGYmNyeXB0AA
3+
AAGAAAABDpxH70vULcphyZoyd8Roc8AAAAEAAAAAEAAAAzAAAAC3NzaC1lZDI1NTE5AAAA
4+
ID3cbY1HvyY8fXNrcESSpXm/3nGKpjxNjrlD+U5oMVmxAAAAoGXyMyKfXmGAVGLI3jbwk5
5+
gqrHAEFI5HHuYfW1DAAjSlj41paovl9tk7jZLIGslLUrUkN8Ac6ACNYuCWQZPgPr5mXHDx
6+
x+8GpdYPrgamB74lVwEPwk1BVjJRYUDRJ0SWyHagybITJhutq7yH+hZS+S05q2EeVee4Cn
7+
ZqESfmPZwdHg41IkdVTrZcLzadjPrcqrGzg1P7T9zl9hLD2QfBmO+XN2FPM+ufOGxpHHyJ
8+
SY/c
9+
-----END OPENSSH PRIVATE KEY-----
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAID3cbY1HvyY8fXNrcESSpXm/3nGKpjxNjrlD+U5oMVmx

0 commit comments

Comments
 (0)