Skip to content

Commit a85a3b1

Browse files
committed
Better handling of Putty keys with non-ASCII passphrases
The encoding of Putty passphrases is not specified, and is not recorded in *.ppk file headers. Putty on Windows uses whatever the Windows ANSI code page is. (I suppose that gives trouble if it changes between the time the key is generated and the time it is used.) So when trying to decode an encrypted private key from Putty, we may need to try different encodings if the passphrase is not pure ASCII. Change the code to try first UTF-8, then the native encoding unless that also is UTF-8, and finally ISO-8859-1. Respect the "native.encoding" system property that should be set on Java >= 17. See JEP-400.[1] [1] https://openjdk.org/jeps/400
1 parent 6c215e8 commit a85a3b1

5 files changed

Lines changed: 128 additions & 9 deletions

File tree

CHANGES.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,13 @@
3939

4040
Wildcard principals in host certificates are handled now.
4141

42+
* Putty keys with non-ASCII passphrases
43+
44+
The passphrase needs to be converted to a byte sequence to compute a decryption key for an encrypted private key. This
45+
conversion depends on the character encoding. Putty on Windows uses the ANSI codepage set when the key was generated.
46+
Apache MINA SSHD now tries multiple encodings in sequence: UTF-8, then the OS encoding, and finally ISO-8859-1 as a
47+
last-chance fallback.
48+
4249
## Potential Compatibility Issues
4350

4451
* [GH-892](https://github.com/apache/mina-sshd/issues/892) Align handling certificates without principals with OpenSSH 10.3

sshd-putty/src/main/java/org/apache/sshd/putty/AbstractPuttyKeyDecoder.java

Lines changed: 50 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@
2323
import java.io.IOException;
2424
import java.io.InputStream;
2525
import java.io.StreamCorruptedException;
26+
import java.nio.charset.Charset;
27+
import java.nio.charset.StandardCharsets;
2628
import java.security.GeneralSecurityException;
2729
import java.security.KeyPair;
2830
import java.security.PrivateKey;
@@ -171,6 +173,10 @@ public Collection<KeyPair> loadKeyPairs(
171173
prvEncryption, passwordProvider, headers);
172174
}
173175

176+
private interface KeyDecoder {
177+
byte[] decode() throws GeneralSecurityException;
178+
}
179+
174180
public Collection<KeyPair> loadKeyPairs(
175181
SessionContext session, NamedResource resourceKey, int formatVersion,
176182
String pubData, String prvData, String prvEncryption,
@@ -212,17 +218,57 @@ public Collection<KeyPair> loadKeyPairs(
212218
String algorithm = algName;
213219
int bits = numBits;
214220
Collection<KeyPair> keys = passwordProvider.decode(session, resourceKey, password -> {
215-
byte[] decBytes = PuttyKeyPairResourceParser.decodePrivateKeyBytes(formatVersion, prvBytes, algorithm, bits, mode,
216-
password, headers);
221+
KeyDecoder decoder = () -> PuttyKeyPairResourceParser.decodePrivateKeyBytes(formatVersion, prvBytes, algorithm,
222+
bits, mode, password, headers);
217223
try {
218-
return loadKeyPairs(resourceKey, formatVersion, pubBytes, decBytes, headers);
224+
return loadEncryptedKeyPairs(resourceKey, formatVersion, pubBytes, headers, decoder);
225+
} catch (GeneralSecurityException | IOException e) {
226+
// If the password contains non-ASCII characters, Putty may use whatever the current ANSI codepage was
227+
// on Windows when the key was generated. In the Western world that's most likely Windows-1252, which is
228+
// close to ISO-8859-1.
229+
//
230+
// So let's try with the native encoding, and if that fails, with ISO-8859-1.
231+
if (password.chars().anyMatch(val -> val != (val & 0x7F))) {
232+
// JEP 400: Java 18 populates this system property.
233+
String encoding = System.getProperty("native.encoding"); //$NON-NLS-1$
234+
if (encoding == null || encoding.isEmpty()) {
235+
encoding = Charset.defaultCharset().name();
236+
}
237+
if (encoding != null && !encoding.isEmpty() && !StandardCharsets.UTF_8.name().equals(encoding)) {
238+
try {
239+
headers.put(PuttyKeyPairResourceParser.SSHD_PASSWORD_ENCODING, encoding);
240+
return loadEncryptedKeyPairs(resourceKey, formatVersion, pubBytes, headers, decoder);
241+
} catch (GeneralSecurityException | IOException e2) {
242+
if (StandardCharsets.ISO_8859_1.name().equals(encoding)) {
243+
// No point trying again
244+
throw e2;
245+
}
246+
// Ignore and try ISO-8859-1 below
247+
}
248+
}
249+
headers.put(PuttyKeyPairResourceParser.SSHD_PASSWORD_ENCODING, StandardCharsets.ISO_8859_1.name());
250+
return loadEncryptedKeyPairs(resourceKey, formatVersion, pubBytes, headers, decoder);
251+
} else {
252+
throw e;
253+
}
219254
} finally {
220-
Arrays.fill(decBytes, (byte) 0); // eliminate sensitive data a.s.a.p.
255+
headers.remove(PuttyKeyPairResourceParser.SSHD_PASSWORD_ENCODING);
221256
}
222257
});
223258
return keys == null ? Collections.emptyList() : keys;
224259
}
225260

261+
private Collection<KeyPair> loadEncryptedKeyPairs(
262+
NamedResource resourceKey, int formatVersion, byte[] pubBytes, Map<String, String> headers, KeyDecoder decoder)
263+
throws GeneralSecurityException, IOException {
264+
byte[] decBytes = decoder.decode();
265+
try {
266+
return loadKeyPairs(resourceKey, formatVersion, pubBytes, decBytes, headers);
267+
} finally {
268+
Arrays.fill(decBytes, (byte) 0);
269+
}
270+
}
271+
226272
public Collection<KeyPair> loadKeyPairs(
227273
NamedResource resourceKey, int formatVersion, byte[] pubData, byte[] prvData, Map<String, String> headers)
228274
throws IOException, GeneralSecurityException {

sshd-putty/src/main/java/org/apache/sshd/putty/PuttyKeyPairResourceParser.java

Lines changed: 44 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,10 @@
2020
package org.apache.sshd.putty;
2121

2222
import java.io.IOException;
23+
import java.nio.charset.Charset;
24+
import java.nio.charset.IllegalCharsetNameException;
2325
import java.nio.charset.StandardCharsets;
26+
import java.nio.charset.UnsupportedCharsetException;
2427
import java.security.GeneralSecurityException;
2528
import java.security.KeyPair;
2629
import java.security.MessageDigest;
@@ -30,6 +33,7 @@
3033
import java.security.spec.InvalidKeySpecException;
3134
import java.util.Arrays;
3235
import java.util.Collections;
36+
import java.util.HashMap;
3337
import java.util.List;
3438
import java.util.Map;
3539
import java.util.Objects;
@@ -103,6 +107,7 @@ public interface PuttyKeyPairResourceParser<PUB extends PublicKey, PRV extends P
103107
String KEY_FILE_HEADER_PREFIX = "PuTTY-User-Key-File-";
104108
String PUBLIC_LINES_HEADER = "Public-Lines";
105109
String PRIVATE_LINES_HEADER = "Private-Lines";
110+
String SSHD_PASSWORD_ENCODING = "Sshd-Password-Encoding";
106111
String PPK_FILE_SUFFIX = ".ppk";
107112

108113
List<String> KNOWN_HEADERS = Collections.unmodifiableList(
@@ -192,7 +197,7 @@ static byte[] decodePrivateKeyBytes(
192197
* when it's encrypted.
193198
*
194199
* @param formatVersion The file format version
195-
* @param passphrase The Password to be used as seed for the key - ignored if {@code null}/empty
200+
* @param passphrase The password to be used as seed for the key; must not be {@code null}
196201
* @param iv Initialization vector to be populated if necessary
197202
* @param key Key to be populated
198203
* @param headers Any extra headers found in the PPK file that might be used for KDF
@@ -225,7 +230,16 @@ static void deriveFormat3EncryptionKey(
225230
byte[] salt = ValidateUtils.checkNotNullAndNotEmpty(
226231
getHexArrayHeaderValue(headers, "Argon2-Salt"), "No Argon2 salt value provided");
227232
byte[] hashValue = new byte[key.length + iv.length + FORMAT_3_MAC_KEY_LENGTH];
228-
byte[] passBytes = passphrase.getBytes(StandardCharsets.UTF_8);
233+
Charset passwordEncoding = StandardCharsets.UTF_8;
234+
String charsetName = headers.get(SSHD_PASSWORD_ENCODING);
235+
if (charsetName != null) {
236+
try {
237+
passwordEncoding = Charset.forName(charsetName);
238+
} catch (UnsupportedCharsetException e) {
239+
// Ignore
240+
}
241+
}
242+
byte[] passBytes = passphrase.getBytes(passwordEncoding);
229243
try {
230244
Argon2Parameters.Builder builder;
231245
if ("Argon2id".equalsIgnoreCase(keyDerivationType)) {
@@ -276,7 +290,7 @@ static int getIntegerHeaderValue(Map<String, String> headers, String key) {
276290
/**
277291
* Uses the &quot;legacy&quot; KDF via SHA-1
278292
*
279-
* @param passphrase The Password to be used as seed for the key - ignored if {@code null}/empty
293+
* @param passphrase The password to be used as seed for the key; must not be {@code null}
280294
* @param iv Initialization vector to be populated if necessary
281295
* @param key Key to be populated
282296
* @throws GeneralSecurityException If cannot retrieve SHA-1 digest
@@ -285,9 +299,35 @@ static int getIntegerHeaderValue(Map<String, String> headers, String key) {
285299
* How does Putty derive the encryption key in its .ppk format ?</A>
286300
*/
287301
static void deriveFormat2EncryptionKey(String passphrase, byte[] iv, byte[] key) throws GeneralSecurityException {
302+
deriveFormat2EncryptionKey(passphrase, iv, key, new HashMap<>());
303+
}
304+
305+
/**
306+
* Uses the &quot;legacy&quot; KDF via SHA-1
307+
*
308+
* @param passphrase The password to be used as seed for the key; must not be {@code null}
309+
* @param iv Initialization vector to be populated if necessary
310+
* @param key Key to be populated
311+
* @param headers Extra headers from the PPK file
312+
* @throws GeneralSecurityException If cannot retrieve SHA-1 digest
313+
* @see <A HREF=
314+
* "http://security.stackexchange.com/questions/71341/how-does-putty-derive-the-encryption-key-in-its-ppk-format">
315+
* How does Putty derive the encryption key in its .ppk format ?</A>
316+
*/
317+
static void deriveFormat2EncryptionKey(String passphrase, byte[] iv, byte[] key, Map<String, String> headers)
318+
throws GeneralSecurityException {
288319
Objects.requireNonNull(passphrase, "No passphrase provded");
289320

290-
byte[] passBytes = passphrase.getBytes(StandardCharsets.UTF_8);
321+
Charset passwordEncoding = StandardCharsets.UTF_8;
322+
String charsetName = headers.get(SSHD_PASSWORD_ENCODING);
323+
if (charsetName != null) {
324+
try {
325+
passwordEncoding = Charset.forName(charsetName);
326+
} catch (IllegalCharsetNameException | UnsupportedCharsetException e) {
327+
// Ignore
328+
}
329+
}
330+
byte[] passBytes = passphrase.getBytes(passwordEncoding);
291331
try {
292332
MessageDigest hash = SecurityUtils.getMessageDigest(BuiltinDigests.sha1.getAlgorithm());
293333
byte[] stateValue = { 0, 0, 0, 0 };

sshd-putty/src/test/java/org/apache/sshd/putty/PuttySpecialKeysTest.java

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525

2626
import org.apache.sshd.common.util.security.SecurityUtils;
2727
import org.junit.jupiter.api.Assumptions;
28+
import org.junit.jupiter.api.BeforeAll;
2829
import org.junit.jupiter.api.MethodOrderer.MethodName;
2930
import org.junit.jupiter.api.Tag;
3031
import org.junit.jupiter.api.Test;
@@ -40,10 +41,14 @@ public PuttySpecialKeysTest() {
4041
super();
4142
}
4243

44+
@BeforeAll
45+
static void assumeBouncyCastle() {
46+
Assumptions.assumeTrue(SecurityUtils.isBouncyCastleRegistered(), "BC provider available");
47+
}
48+
4349
// SSHD-1247
4450
@Test
4551
void argon2KeyDerivation() throws Exception {
46-
Assumptions.assumeTrue(SecurityUtils.isBouncyCastleRegistered(), "BC provider available");
4752
testDecodeSpecialEncryptedPuttyKeyFile("ssh-rsa", "argon2id", "123456");
4853
}
4954

@@ -56,4 +61,10 @@ protected KeyPair testDecodeSpecialEncryptedPuttyKeyFile(
5661
+ "-" + password + PuttyKeyPairResourceParser.PPK_FILE_SUFFIX,
5762
false, password, keyType);
5863
}
64+
65+
@Test
66+
void nonAsciiPassphrase() throws Exception {
67+
testDecodeEncryptedPuttyKeyFile("non-ascii-passphrase-encrypted-KeyPair" + PuttyKeyPairResourceParser.PPK_FILE_SUFFIX,
68+
false, "secret123äöüß", "ecdsa-sha2-nistp256");
69+
}
5970
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
PuTTY-User-Key-File-3: ecdsa-sha2-nistp256
2+
Encryption: aes256-cbc
3+
Comment: ecdsa-key-20260417
4+
Public-Lines: 3
5+
AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBGXm6QPAlc2K
6+
35/WrbOK2BWAJ9rZCt1JZxN2ST4v4C5co6MT8GsGKt0SVc/tzE2sC9w85bZR9rhu
7+
J5cTdm/fdac=
8+
Key-Derivation: Argon2id
9+
Argon2-Memory: 8192
10+
Argon2-Passes: 34
11+
Argon2-Parallelism: 1
12+
Argon2-Salt: 837714b0fd5cd436fe5d5727943fbb21
13+
Private-Lines: 1
14+
sijTJ9kQFECRzS/9dHKN8r1iDvRQB4OXWAbLZJjPn+vjCm1HDlO3XcUDEtBMpdwM
15+
Private-MAC: 25cdb7650f2a829d743b3efa98013f5b1a2415cc4bf945704e794c3dff3d1183

0 commit comments

Comments
 (0)