2929import net .schmizz .sshj .userauth .password .PasswordUtils ;
3030import org .bouncycastle .asn1 .nist .NISTNamedCurves ;
3131import org .bouncycastle .asn1 .x9 .X9ECParameters ;
32+ import org .bouncycastle .crypto .generators .Argon2BytesGenerator ;
33+ import org .bouncycastle .crypto .params .Argon2Parameters ;
3234import org .bouncycastle .jce .spec .ECNamedCurveSpec ;
3335import org .bouncycastle .util .encoders .Hex ;
3436
4042import java .math .BigInteger ;
4143import java .security .*;
4244import java .security .spec .*;
45+ import java .util .Arrays ;
4346import java .util .HashMap ;
47+ import java .util .LinkedList ;
48+ import java .util .List ;
4449import 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}
0 commit comments