This is an automated email from the ASF dual-hosted git repository. tomaswolf pushed a commit to branch master in repository https://gitbox.apache.org/repos/asf/mina-sshd.git
commit a85a3b1cbaab2d8f4f50e57df1192f4e50960b8f Author: Thomas Wolf <[email protected]> AuthorDate: Sun Apr 19 13:06:34 2026 +0200 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 --- CHANGES.md | 7 +++ .../apache/sshd/putty/AbstractPuttyKeyDecoder.java | 54 ++++++++++++++++++++-- .../sshd/putty/PuttyKeyPairResourceParser.java | 48 +++++++++++++++++-- .../apache/sshd/putty/PuttySpecialKeysTest.java | 13 +++++- .../non-ascii-passphrase-encrypted-KeyPair.ppk | 15 ++++++ 5 files changed, 128 insertions(+), 9 deletions(-) diff --git a/CHANGES.md b/CHANGES.md index 1f407b6e1..8315e299b 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -39,6 +39,13 @@ Wildcard principals in host certificates are handled now. +* Putty keys with non-ASCII passphrases + +The passphrase needs to be converted to a byte sequence to compute a decryption key for an encrypted private key. This +conversion depends on the character encoding. Putty on Windows uses the ANSI codepage set when the key was generated. +Apache MINA SSHD now tries multiple encodings in sequence: UTF-8, then the OS encoding, and finally ISO-8859-1 as a +last-chance fallback. + ## Potential Compatibility Issues * [GH-892](https://github.com/apache/mina-sshd/issues/892) Align handling certificates without principals with OpenSSH 10.3 diff --git a/sshd-putty/src/main/java/org/apache/sshd/putty/AbstractPuttyKeyDecoder.java b/sshd-putty/src/main/java/org/apache/sshd/putty/AbstractPuttyKeyDecoder.java index 9bc7106d6..6c5f19de1 100644 --- a/sshd-putty/src/main/java/org/apache/sshd/putty/AbstractPuttyKeyDecoder.java +++ b/sshd-putty/src/main/java/org/apache/sshd/putty/AbstractPuttyKeyDecoder.java @@ -23,6 +23,8 @@ import java.io.ByteArrayInputStream; import java.io.IOException; import java.io.InputStream; import java.io.StreamCorruptedException; +import java.nio.charset.Charset; +import java.nio.charset.StandardCharsets; import java.security.GeneralSecurityException; import java.security.KeyPair; import java.security.PrivateKey; @@ -171,6 +173,10 @@ public abstract class AbstractPuttyKeyDecoder<PUB extends PublicKey, PRV extends prvEncryption, passwordProvider, headers); } + private interface KeyDecoder { + byte[] decode() throws GeneralSecurityException; + } + public Collection<KeyPair> loadKeyPairs( SessionContext session, NamedResource resourceKey, int formatVersion, String pubData, String prvData, String prvEncryption, @@ -212,17 +218,57 @@ public abstract class AbstractPuttyKeyDecoder<PUB extends PublicKey, PRV extends String algorithm = algName; int bits = numBits; Collection<KeyPair> keys = passwordProvider.decode(session, resourceKey, password -> { - byte[] decBytes = PuttyKeyPairResourceParser.decodePrivateKeyBytes(formatVersion, prvBytes, algorithm, bits, mode, - password, headers); + KeyDecoder decoder = () -> PuttyKeyPairResourceParser.decodePrivateKeyBytes(formatVersion, prvBytes, algorithm, + bits, mode, password, headers); try { - return loadKeyPairs(resourceKey, formatVersion, pubBytes, decBytes, headers); + return loadEncryptedKeyPairs(resourceKey, formatVersion, pubBytes, headers, decoder); + } catch (GeneralSecurityException | IOException e) { + // If the password contains non-ASCII characters, Putty may use whatever the current ANSI codepage was + // on Windows when the key was generated. In the Western world that's most likely Windows-1252, which is + // close to ISO-8859-1. + // + // So let's try with the native encoding, and if that fails, with ISO-8859-1. + if (password.chars().anyMatch(val -> val != (val & 0x7F))) { + // JEP 400: Java 18 populates this system property. + String encoding = System.getProperty("native.encoding"); //$NON-NLS-1$ + if (encoding == null || encoding.isEmpty()) { + encoding = Charset.defaultCharset().name(); + } + if (encoding != null && !encoding.isEmpty() && !StandardCharsets.UTF_8.name().equals(encoding)) { + try { + headers.put(PuttyKeyPairResourceParser.SSHD_PASSWORD_ENCODING, encoding); + return loadEncryptedKeyPairs(resourceKey, formatVersion, pubBytes, headers, decoder); + } catch (GeneralSecurityException | IOException e2) { + if (StandardCharsets.ISO_8859_1.name().equals(encoding)) { + // No point trying again + throw e2; + } + // Ignore and try ISO-8859-1 below + } + } + headers.put(PuttyKeyPairResourceParser.SSHD_PASSWORD_ENCODING, StandardCharsets.ISO_8859_1.name()); + return loadEncryptedKeyPairs(resourceKey, formatVersion, pubBytes, headers, decoder); + } else { + throw e; + } } finally { - Arrays.fill(decBytes, (byte) 0); // eliminate sensitive data a.s.a.p. + headers.remove(PuttyKeyPairResourceParser.SSHD_PASSWORD_ENCODING); } }); return keys == null ? Collections.emptyList() : keys; } + private Collection<KeyPair> loadEncryptedKeyPairs( + NamedResource resourceKey, int formatVersion, byte[] pubBytes, Map<String, String> headers, KeyDecoder decoder) + throws GeneralSecurityException, IOException { + byte[] decBytes = decoder.decode(); + try { + return loadKeyPairs(resourceKey, formatVersion, pubBytes, decBytes, headers); + } finally { + Arrays.fill(decBytes, (byte) 0); + } + } + public Collection<KeyPair> loadKeyPairs( NamedResource resourceKey, int formatVersion, byte[] pubData, byte[] prvData, Map<String, String> headers) throws IOException, GeneralSecurityException { diff --git a/sshd-putty/src/main/java/org/apache/sshd/putty/PuttyKeyPairResourceParser.java b/sshd-putty/src/main/java/org/apache/sshd/putty/PuttyKeyPairResourceParser.java index 5398940f6..dd5fe1a9e 100644 --- a/sshd-putty/src/main/java/org/apache/sshd/putty/PuttyKeyPairResourceParser.java +++ b/sshd-putty/src/main/java/org/apache/sshd/putty/PuttyKeyPairResourceParser.java @@ -20,7 +20,10 @@ package org.apache.sshd.putty; import java.io.IOException; +import java.nio.charset.Charset; +import java.nio.charset.IllegalCharsetNameException; import java.nio.charset.StandardCharsets; +import java.nio.charset.UnsupportedCharsetException; import java.security.GeneralSecurityException; import java.security.KeyPair; import java.security.MessageDigest; @@ -30,6 +33,7 @@ import java.security.PublicKey; import java.security.spec.InvalidKeySpecException; import java.util.Arrays; import java.util.Collections; +import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Objects; @@ -103,6 +107,7 @@ public interface PuttyKeyPairResourceParser<PUB extends PublicKey, PRV extends P String KEY_FILE_HEADER_PREFIX = "PuTTY-User-Key-File-"; String PUBLIC_LINES_HEADER = "Public-Lines"; String PRIVATE_LINES_HEADER = "Private-Lines"; + String SSHD_PASSWORD_ENCODING = "Sshd-Password-Encoding"; String PPK_FILE_SUFFIX = ".ppk"; List<String> KNOWN_HEADERS = Collections.unmodifiableList( @@ -192,7 +197,7 @@ public interface PuttyKeyPairResourceParser<PUB extends PublicKey, PRV extends P * when it's encrypted. * * @param formatVersion The file format version - * @param passphrase The Password to be used as seed for the key - ignored if {@code null}/empty + * @param passphrase The password to be used as seed for the key; must not be {@code null} * @param iv Initialization vector to be populated if necessary * @param key Key to be populated * @param headers Any extra headers found in the PPK file that might be used for KDF @@ -225,7 +230,16 @@ public interface PuttyKeyPairResourceParser<PUB extends PublicKey, PRV extends P byte[] salt = ValidateUtils.checkNotNullAndNotEmpty( getHexArrayHeaderValue(headers, "Argon2-Salt"), "No Argon2 salt value provided"); byte[] hashValue = new byte[key.length + iv.length + FORMAT_3_MAC_KEY_LENGTH]; - byte[] passBytes = passphrase.getBytes(StandardCharsets.UTF_8); + Charset passwordEncoding = StandardCharsets.UTF_8; + String charsetName = headers.get(SSHD_PASSWORD_ENCODING); + if (charsetName != null) { + try { + passwordEncoding = Charset.forName(charsetName); + } catch (UnsupportedCharsetException e) { + // Ignore + } + } + byte[] passBytes = passphrase.getBytes(passwordEncoding); try { Argon2Parameters.Builder builder; if ("Argon2id".equalsIgnoreCase(keyDerivationType)) { @@ -276,7 +290,7 @@ public interface PuttyKeyPairResourceParser<PUB extends PublicKey, PRV extends P /** * Uses the "legacy" KDF via SHA-1 * - * @param passphrase The Password to be used as seed for the key - ignored if {@code null}/empty + * @param passphrase The password to be used as seed for the key; must not be {@code null} * @param iv Initialization vector to be populated if necessary * @param key Key to be populated * @throws GeneralSecurityException If cannot retrieve SHA-1 digest @@ -285,9 +299,35 @@ public interface PuttyKeyPairResourceParser<PUB extends PublicKey, PRV extends P * How does Putty derive the encryption key in its .ppk format ?</A> */ static void deriveFormat2EncryptionKey(String passphrase, byte[] iv, byte[] key) throws GeneralSecurityException { + deriveFormat2EncryptionKey(passphrase, iv, key, new HashMap<>()); + } + + /** + * Uses the "legacy" KDF via SHA-1 + * + * @param passphrase The password to be used as seed for the key; must not be {@code null} + * @param iv Initialization vector to be populated if necessary + * @param key Key to be populated + * @param headers Extra headers from the PPK file + * @throws GeneralSecurityException If cannot retrieve SHA-1 digest + * @see <A HREF= + * "http://security.stackexchange.com/questions/71341/how-does-putty-derive-the-encryption-key-in-its-ppk-format"> + * How does Putty derive the encryption key in its .ppk format ?</A> + */ + static void deriveFormat2EncryptionKey(String passphrase, byte[] iv, byte[] key, Map<String, String> headers) + throws GeneralSecurityException { Objects.requireNonNull(passphrase, "No passphrase provded"); - byte[] passBytes = passphrase.getBytes(StandardCharsets.UTF_8); + Charset passwordEncoding = StandardCharsets.UTF_8; + String charsetName = headers.get(SSHD_PASSWORD_ENCODING); + if (charsetName != null) { + try { + passwordEncoding = Charset.forName(charsetName); + } catch (IllegalCharsetNameException | UnsupportedCharsetException e) { + // Ignore + } + } + byte[] passBytes = passphrase.getBytes(passwordEncoding); try { MessageDigest hash = SecurityUtils.getMessageDigest(BuiltinDigests.sha1.getAlgorithm()); byte[] stateValue = { 0, 0, 0, 0 }; diff --git a/sshd-putty/src/test/java/org/apache/sshd/putty/PuttySpecialKeysTest.java b/sshd-putty/src/test/java/org/apache/sshd/putty/PuttySpecialKeysTest.java index cad8505db..80e6b51ba 100644 --- a/sshd-putty/src/test/java/org/apache/sshd/putty/PuttySpecialKeysTest.java +++ b/sshd-putty/src/test/java/org/apache/sshd/putty/PuttySpecialKeysTest.java @@ -25,6 +25,7 @@ import java.security.KeyPair; import org.apache.sshd.common.util.security.SecurityUtils; import org.junit.jupiter.api.Assumptions; +import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.MethodOrderer.MethodName; import org.junit.jupiter.api.Tag; import org.junit.jupiter.api.Test; @@ -40,10 +41,14 @@ public class PuttySpecialKeysTest extends AbstractPuttyTestSupport { super(); } + @BeforeAll + static void assumeBouncyCastle() { + Assumptions.assumeTrue(SecurityUtils.isBouncyCastleRegistered(), "BC provider available"); + } + // SSHD-1247 @Test void argon2KeyDerivation() throws Exception { - Assumptions.assumeTrue(SecurityUtils.isBouncyCastleRegistered(), "BC provider available"); testDecodeSpecialEncryptedPuttyKeyFile("ssh-rsa", "argon2id", "123456"); } @@ -56,4 +61,10 @@ public class PuttySpecialKeysTest extends AbstractPuttyTestSupport { + "-" + password + PuttyKeyPairResourceParser.PPK_FILE_SUFFIX, false, password, keyType); } + + @Test + void nonAsciiPassphrase() throws Exception { + testDecodeEncryptedPuttyKeyFile("non-ascii-passphrase-encrypted-KeyPair" + PuttyKeyPairResourceParser.PPK_FILE_SUFFIX, + false, "secret123äöüß", "ecdsa-sha2-nistp256"); + } } diff --git a/sshd-putty/src/test/resources/org/apache/sshd/putty/non-ascii-passphrase-encrypted-KeyPair.ppk b/sshd-putty/src/test/resources/org/apache/sshd/putty/non-ascii-passphrase-encrypted-KeyPair.ppk new file mode 100644 index 000000000..a22af4f5b --- /dev/null +++ b/sshd-putty/src/test/resources/org/apache/sshd/putty/non-ascii-passphrase-encrypted-KeyPair.ppk @@ -0,0 +1,15 @@ +PuTTY-User-Key-File-3: ecdsa-sha2-nistp256 +Encryption: aes256-cbc +Comment: ecdsa-key-20260417 +Public-Lines: 3 +AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBGXm6QPAlc2K +35/WrbOK2BWAJ9rZCt1JZxN2ST4v4C5co6MT8GsGKt0SVc/tzE2sC9w85bZR9rhu +J5cTdm/fdac= +Key-Derivation: Argon2id +Argon2-Memory: 8192 +Argon2-Passes: 34 +Argon2-Parallelism: 1 +Argon2-Salt: 837714b0fd5cd436fe5d5727943fbb21 +Private-Lines: 1 +sijTJ9kQFECRzS/9dHKN8r1iDvRQB4OXWAbLZJjPn+vjCm1HDlO3XcUDEtBMpdwM +Private-MAC: 25cdb7650f2a829d743b3efa98013f5b1a2415cc4bf945704e794c3dff3d1183
