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 &quot;legacy&quot; 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 &quot;legacy&quot; 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

Reply via email to