Skip to content

Commit b2115de

Browse files
authored
full support for encrypted PuTTY v3 files (#730)
* full support for encrypted PuTTY v3 files (Argon2 library not included) * simplified the PuTTYKeyDerivation interface and provided an abstract PuTTYArgon2 class for an easy Argon2 integration * use Argon2 implementation from Bouncy Castle * missing license header added * license header again * unit tests extended to cover all Argon2 variants and non-standard Argon2 parameters; verify the loaded keys
1 parent d6d6f0d commit b2115de

File tree

5 files changed

+366
-163
lines changed

5 files changed

+366
-163
lines changed

src/main/java/net/schmizz/sshj/userauth/keyprovider/PuTTYKeyFile.java

Lines changed: 152 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,8 @@
2929
import net.schmizz.sshj.userauth.password.PasswordUtils;
3030
import org.bouncycastle.asn1.nist.NISTNamedCurves;
3131
import org.bouncycastle.asn1.x9.X9ECParameters;
32+
import org.bouncycastle.crypto.generators.Argon2BytesGenerator;
33+
import org.bouncycastle.crypto.params.Argon2Parameters;
3234
import org.bouncycastle.jce.spec.ECNamedCurveSpec;
3335
import org.bouncycastle.util.encoders.Hex;
3436

@@ -40,7 +42,10 @@
4042
import java.math.BigInteger;
4143
import java.security.*;
4244
import java.security.spec.*;
45+
import java.util.Arrays;
4346
import java.util.HashMap;
47+
import java.util.LinkedList;
48+
import java.util.List;
4449
import java.util.Map;
4550

4651
/**
@@ -84,20 +89,18 @@ public String getName() {
8489
}
8590
}
8691

92+
private Integer keyFileVersion;
8793
private byte[] privateKey;
8894
private byte[] publicKey;
95+
private byte[] verifyHmac; // only used by v3 keys
8996

9097
/**
9198
* Key type
9299
*/
93100
@Override
94101
public KeyType getType() throws IOException {
95-
for (String h : headers.keySet()) {
96-
if (h.startsWith("PuTTY-User-Key-File-")) {
97-
return KeyType.fromString(headers.get(h));
98-
}
99-
}
100-
return KeyType.UNKNOWN;
102+
String headerName = String.format("PuTTY-User-Key-File-%d", this.keyFileVersion);
103+
return KeyType.fromString(headers.get(headerName));
101104
}
102105

103106
public boolean isEncrypted() throws IOException {
@@ -207,6 +210,7 @@ protected KeyPair readKeyPair() throws IOException {
207210
}
208211

209212
protected void parseKeyPair() throws IOException {
213+
this.keyFileVersion = null;
210214
BufferedReader r = new BufferedReader(resource.getReader());
211215
// Parse the text into headers and payloads
212216
try {
@@ -217,6 +221,9 @@ protected void parseKeyPair() throws IOException {
217221
if (idx > 0) {
218222
headerName = line.substring(0, idx);
219223
headers.put(headerName, line.substring(idx + 2));
224+
if (headerName.startsWith("PuTTY-User-Key-File-")) {
225+
this.keyFileVersion = Integer.parseInt(headerName.substring(20));
226+
}
220227
} else {
221228
String s = payload.get(headerName);
222229
if (s == null) {
@@ -232,6 +239,9 @@ protected void parseKeyPair() throws IOException {
232239
} finally {
233240
r.close();
234241
}
242+
if (this.keyFileVersion == null) {
243+
throw new IOException("Invalid key file format: missing \"PuTTY-User-Key-File-?\" entry");
244+
}
235245
// Retrieve keys from payload
236246
publicKey = Base64.decode(payload.get("Public-Lines"));
237247
if (this.isEncrypted()) {
@@ -242,8 +252,14 @@ protected void parseKeyPair() throws IOException {
242252
passphrase = "".toCharArray();
243253
}
244254
try {
245-
privateKey = this.decrypt(Base64.decode(payload.get("Private-Lines")), new String(passphrase));
246-
this.verify(new String(passphrase));
255+
privateKey = this.decrypt(Base64.decode(payload.get("Private-Lines")), passphrase);
256+
Mac mac;
257+
if (this.keyFileVersion <= 2) {
258+
mac = this.prepareVerifyMacV2(passphrase);
259+
} else {
260+
mac = this.prepareVerifyMacV3();
261+
}
262+
this.verify(mac);
247263
} finally {
248264
PasswordUtils.blankOut(passphrase);
249265
}
@@ -254,103 +270,179 @@ protected void parseKeyPair() throws IOException {
254270

255271
/**
256272
* Converts a passphrase into a key, by following the convention that PuTTY
257-
* uses.
258-
* <p/>
259-
* <p/>
273+
* uses. Only PuTTY v1/v2 key files
274+
* <p><p/>
260275
* This is used to decrypt the private key when it's encrypted.
261276
*/
262-
private byte[] toKey(final String passphrase) throws IOException {
277+
private void initCipher(final char[] passphrase, Cipher cipher) throws IOException, InvalidAlgorithmParameterException, InvalidKeyException {
263278
// The field Key-Derivation has been introduced with Putty v3 key file format
264-
// The only available formats are "Argon2i" "Argon2d" and "Argon2id"
265-
String keyDerivation = headers.get("Key-Derivation");
266-
if (keyDerivation != null) {
267-
throw new IOException(String.format("Unsupported key derivation function: %s", keyDerivation));
279+
// For v3 the algorithms are "Argon2i" "Argon2d" and "Argon2id"
280+
String kdfAlgorithm = headers.get("Key-Derivation");
281+
if (kdfAlgorithm != null) {
282+
kdfAlgorithm = kdfAlgorithm.toLowerCase();
283+
byte[] keyData = this.argon2(kdfAlgorithm, passphrase);
284+
if (keyData == null) {
285+
throw new IOException(String.format("Unsupported key derivation function: %s", kdfAlgorithm));
286+
}
287+
byte[] key = new byte[32];
288+
byte[] iv = new byte[16];
289+
byte[] tag = new byte[32]; // Hmac key
290+
System.arraycopy(keyData, 0, key, 0, 32);
291+
System.arraycopy(keyData, 32, iv, 0, 16);
292+
System.arraycopy(keyData, 48, tag, 0, 32);
293+
cipher.init(Cipher.DECRYPT_MODE, new SecretKeySpec(key, "AES"),
294+
new IvParameterSpec(iv));
295+
verifyHmac = tag;
296+
return;
268297
}
298+
299+
// Key file format v1 + v2
269300
try {
270301
MessageDigest digest = MessageDigest.getInstance("SHA-1");
271302

272303
// The encryption key is derived from the passphrase by means of a succession of
273304
// SHA-1 hashes.
305+
byte[] encodedPassphrase = PasswordUtils.toByteArray(passphrase);
274306

275307
// Sequence number 0
276-
digest.update(new byte[] { 0, 0, 0, 0 });
277-
digest.update(passphrase.getBytes());
308+
digest.update(new byte[]{0, 0, 0, 0});
309+
digest.update(encodedPassphrase);
278310
byte[] key1 = digest.digest();
279311

280312
// Sequence number 1
281-
digest.update(new byte[] { 0, 0, 0, 1 });
282-
digest.update(passphrase.getBytes());
313+
digest.update(new byte[]{0, 0, 0, 1});
314+
digest.update(encodedPassphrase);
283315
byte[] key2 = digest.digest();
284316

285-
byte[] r = new byte[32];
286-
System.arraycopy(key1, 0, r, 0, 20);
287-
System.arraycopy(key2, 0, r, 20, 12);
317+
Arrays.fill(encodedPassphrase, (byte) 0);
318+
319+
byte[] expanded = new byte[32];
320+
System.arraycopy(key1, 0, expanded, 0, 20);
321+
System.arraycopy(key2, 0, expanded, 20, 12);
322+
323+
cipher.init(Cipher.DECRYPT_MODE, new SecretKeySpec(expanded, 0, 32, "AES"),
324+
new IvParameterSpec(new byte[16])); // initial vector=0
288325

289-
return r;
290326
} catch (NoSuchAlgorithmException e) {
291327
throw new IOException(e.getMessage(), e);
292328
}
293329
}
294330

295331
/**
296-
* Verify the MAC.
332+
* Uses BouncyCastle Argon2 implementation
333+
*/
334+
private byte[] argon2(String algorithm, final char[] passphrase) throws IOException {
335+
int type;
336+
if ("argon2i".equals(algorithm)) {
337+
type = Argon2Parameters.ARGON2_i;
338+
} else if ("argon2d".equals(algorithm)) {
339+
type = Argon2Parameters.ARGON2_d;
340+
} else if ("argon2id".equals(algorithm)) {
341+
type = Argon2Parameters.ARGON2_id;
342+
} else {
343+
return null;
344+
}
345+
byte[] salt = Hex.decode(headers.get("Argon2-Salt"));
346+
int iterations = Integer.parseInt(headers.get("Argon2-Passes"));
347+
int memory = Integer.parseInt(headers.get("Argon2-Memory"));
348+
int parallelism = Integer.parseInt(headers.get("Argon2-Parallelism"));
349+
350+
Argon2Parameters a2p = new Argon2Parameters.Builder(type)
351+
.withVersion(Argon2Parameters.ARGON2_VERSION_13)
352+
.withIterations(iterations)
353+
.withMemoryAsKB(memory)
354+
.withParallelism(parallelism)
355+
.withSalt(salt).build();
356+
357+
Argon2BytesGenerator generator = new Argon2BytesGenerator();
358+
generator.init(a2p);
359+
byte[] output = new byte[80];
360+
int bytes = generator.generateBytes(passphrase, output);
361+
if (bytes != output.length) {
362+
throw new IOException("Failed to generate key via Argon2");
363+
}
364+
return output;
365+
}
366+
367+
/**
368+
* Verify the MAC (only required for v1/v2 keys. v3 keys are automatically
369+
* verified as part of the decryption process.
297370
*/
298-
private void verify(final String passphrase) throws IOException {
371+
private void verify(final Mac mac) throws IOException {
372+
final ByteArrayOutputStream out = new ByteArrayOutputStream(256);
373+
final DataOutputStream data = new DataOutputStream(out);
374+
// name of algorithm
375+
String keyType = this.getType().toString();
376+
data.writeInt(keyType.length());
377+
data.writeBytes(keyType);
378+
379+
data.writeInt(headers.get("Encryption").length());
380+
data.writeBytes(headers.get("Encryption"));
381+
382+
data.writeInt(headers.get("Comment").length());
383+
data.writeBytes(headers.get("Comment"));
384+
385+
data.writeInt(publicKey.length);
386+
data.write(publicKey);
387+
388+
data.writeInt(privateKey.length);
389+
data.write(privateKey);
390+
391+
final String encoded = Hex.toHexString(mac.doFinal(out.toByteArray()));
392+
final String reference = headers.get("Private-MAC");
393+
if (!encoded.equals(reference)) {
394+
throw new IOException("Invalid passphrase");
395+
}
396+
}
397+
398+
private Mac prepareVerifyMacV2(final char[] passphrase) throws IOException {
399+
// The key to the MAC is itself a SHA-1 hash of (v1/v2 key only):
299400
try {
300-
// The key to the MAC is itself a SHA-1 hash of (v1/v2 key only):
301401
MessageDigest digest = MessageDigest.getInstance("SHA-1");
302402
digest.update("putty-private-key-file-mac-key".getBytes());
303403
if (passphrase != null) {
304-
digest.update(passphrase.getBytes());
404+
byte[] encodedPassphrase = PasswordUtils.toByteArray(passphrase);
405+
digest.update(encodedPassphrase);
406+
Arrays.fill(encodedPassphrase, (byte) 0);
305407
}
306408
final byte[] key = digest.digest();
307-
308-
final Mac mac = Mac.getInstance("HmacSHA1");
409+
Mac mac = Mac.getInstance("HmacSHA1");
309410
mac.init(new SecretKeySpec(key, 0, 20, mac.getAlgorithm()));
411+
return mac;
412+
} catch (NoSuchAlgorithmException | InvalidKeyException e) {
413+
throw new IOException(e.getMessage(), e);
414+
}
415+
}
310416

311-
final ByteArrayOutputStream out = new ByteArrayOutputStream();
312-
final DataOutputStream data = new DataOutputStream(out);
313-
// name of algorithm
314-
String keyType = this.getType().toString();
315-
data.writeInt(keyType.length());
316-
data.writeBytes(keyType);
317-
318-
data.writeInt(headers.get("Encryption").length());
319-
data.writeBytes(headers.get("Encryption"));
320-
321-
data.writeInt(headers.get("Comment").length());
322-
data.writeBytes(headers.get("Comment"));
323-
324-
data.writeInt(publicKey.length);
325-
data.write(publicKey);
326-
327-
data.writeInt(privateKey.length);
328-
data.write(privateKey);
329-
330-
final String encoded = Hex.toHexString(mac.doFinal(out.toByteArray()));
331-
final String reference = headers.get("Private-MAC");
332-
if (!encoded.equals(reference)) {
333-
throw new IOException("Invalid passphrase");
334-
}
335-
} catch (GeneralSecurityException e) {
417+
private Mac prepareVerifyMacV3() throws IOException {
418+
// for v3 keys the hMac key is included in the Argon output
419+
try {
420+
Mac mac = Mac.getInstance("HmacSHA256");
421+
mac.init(new SecretKeySpec(this.verifyHmac, 0, 32, mac.getAlgorithm()));
422+
return mac;
423+
} catch (NoSuchAlgorithmException | InvalidKeyException e) {
336424
throw new IOException(e.getMessage(), e);
337425
}
338426
}
339427

340428
/**
341429
* Decrypt private key
342430
*
431+
* @param privateKey the SSH private key to be decrypted
343432
* @param passphrase To decrypt
344433
*/
345-
private byte[] decrypt(final byte[] key, final String passphrase) throws IOException {
434+
private byte[] decrypt(final byte[] privateKey, final char[] passphrase) throws IOException {
346435
try {
347436
final Cipher cipher = Cipher.getInstance("AES/CBC/NoPadding");
348-
final byte[] expanded = this.toKey(passphrase);
349-
cipher.init(Cipher.DECRYPT_MODE, new SecretKeySpec(expanded, 0, 32, "AES"),
350-
new IvParameterSpec(new byte[16])); // initial vector=0
351-
return cipher.doFinal(key);
437+
this.initCipher(passphrase, cipher);
438+
return cipher.doFinal(privateKey);
352439
} catch (GeneralSecurityException e) {
353440
throw new IOException(e.getMessage(), e);
354441
}
355442
}
443+
444+
public int getKeyFileVersion() {
445+
return keyFileVersion;
446+
}
447+
356448
}

src/main/java/net/schmizz/sshj/userauth/password/PasswordUtils.java

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@
1515
*/
1616
package net.schmizz.sshj.userauth.password;
1717

18+
import java.nio.CharBuffer;
19+
import java.nio.charset.StandardCharsets;
1820
import java.util.Arrays;
1921

2022
/** Static utility method and factories */
@@ -54,4 +56,14 @@ public boolean shouldRetry(Resource<?> resource) {
5456
};
5557
}
5658

59+
/**
60+
* Converts a password to a UTF-8 encoded byte array
61+
*
62+
* @param password
63+
* @return
64+
*/
65+
public static byte[] toByteArray(char[] password) {
66+
CharBuffer charBuffer = CharBuffer.wrap(password);
67+
return StandardCharsets.UTF_8.encode(charBuffer).array();
68+
}
5769
}

0 commit comments

Comments
 (0)