Version: 2026.0.0 · TFM: net462/net48 (the CngImpl path) · OS: Windows
Reading an ECDSA .ppk throws for roughly half of all keys — specifically those whose private scalar has the high bit set:
System.Security.Cryptography.CryptographicException: The requested operation is not supported.
at System.Security.Cryptography.NCryptNative.ImportKey(SafeNCryptProviderHandle provider, Byte[] keyBlob, String format)
at System.Security.Cryptography.CngKey.Import(Byte[] keyBlob, String curveName, CngKeyBlobFormat format, CngProvider provider)
at Renci.SshNet.Security.EcdsaKey.CngImpl..ctor(String curve_oid, Int32 cord_size, Byte[] qx, Byte[] qy, Byte[] privatekey)
at Renci.SshNet.Security.EcdsaKey.Import(String curve_oid, Byte[] publickey, Byte[] privatekey)
at Renci.SshNet.PrivateKeyFile.PuTTY.Parse()
Cause
PuTTY stores the ECDSA private scalar as an SSH-2 mpint, which is signed — a leading 0x00 byte is prepended when the top bit is set, so for nistp256 the field is 33 bytes about half the time.
The OpenSSH parser strips that byte, the PuTTY parser does not:
// PrivateKeyFile.OpenSSH.cs:213
parsedKey = new EcdsaKey(curve, publicKey, unencryptedPrivateKey.TrimLeadingZeros());
// PrivateKeyFile.PuTTY.cs:178
parsedKey = new EcdsaKey(curve, pub, prv); // <- no trim
CngImpl then calls privatekey.Pad(cord_size), and Pad is a no-op when length <= data.Length. So a 33-byte scalar survives into a BCRYPT_ECCPRIVATE_BLOB whose header declares cbKey = 32, and NCryptImportKey rejects the mismatched blob.
.NET 8 is unaffected (BclImpl / ECParameters tolerates the extra byte), as is the OpenSSH format on every TFM.
Why the test suite does not catch it
test/Data/Key.PuTTY3.ECDSA.ppk is on the lucky side of the coin flip — its scalar starts 0x47, high bit clear, so the mpint is 32 bytes and no trim is needed. Every other ECDSA .ppk fixture is derived from the same key.
Repro
Generating nistp256 keys, exporting them as PuTTYv3, and reading them back with PrivateKeyFile on net48 — 12 for 12, the mpint length alone predicts the outcome:
FAIL mpint-len=33 OK mpint-len=32
FAIL mpint-len=33 OK mpint-len=32
FAIL mpint-len=33 OK mpint-len=32
puttygen's own output is affected the same way, since it writes the same signed mpint (put_mp_ssh2).
Suggested fix
--- a/src/Renci.SshNet/PrivateKeyFile.PuTTY.cs
+++ b/src/Renci.SshNet/PrivateKeyFile.PuTTY.cs
@@ -172,7 +172,7 @@
case "ecdsa-sha2-nistp256":
case "ecdsa-sha2-nistp384":
case "ecdsa-sha2-nistp521":
var curve = publicKeyReader.ReadString(Encoding.ASCII);
var pub = publicKeyReader.ReadBinary();
var prv = privateKeyReader.ReadBinary();
- parsedKey = new EcdsaKey(curve, pub, prv);
+ parsedKey = new EcdsaKey(curve, pub, prv.TrimLeadingZeros());
break;
That makes the PuTTY parser match what the OpenSSH parser already does at line 213. Pad covers the other direction (a short scalar with genuine leading zeros), so trim-then-pad handles both ends.
A regression fixture needs a .ppk whose scalar has the high bit set, since the current one does not exercise the path. Happy to supply one if useful.
Version: 2026.0.0 · TFM: net462/net48 (the
CngImplpath) · OS: WindowsReading an ECDSA
.ppkthrows for roughly half of all keys — specifically those whose private scalar has the high bit set:Cause
PuTTY stores the ECDSA private scalar as an SSH-2
mpint, which is signed — a leading0x00byte is prepended when the top bit is set, so for nistp256 the field is 33 bytes about half the time.The OpenSSH parser strips that byte, the PuTTY parser does not:
CngImplthen callsprivatekey.Pad(cord_size), andPadis a no-op whenlength <= data.Length. So a 33-byte scalar survives into aBCRYPT_ECCPRIVATE_BLOBwhose header declarescbKey = 32, andNCryptImportKeyrejects the mismatched blob..NET 8 is unaffected (
BclImpl/ECParameterstolerates the extra byte), as is the OpenSSH format on every TFM.Why the test suite does not catch it
test/Data/Key.PuTTY3.ECDSA.ppkis on the lucky side of the coin flip — its scalar starts0x47, high bit clear, so the mpint is 32 bytes and no trim is needed. Every other ECDSA.ppkfixture is derived from the same key.Repro
Generating nistp256 keys, exporting them as PuTTYv3, and reading them back with
PrivateKeyFileon net48 — 12 for 12, the mpint length alone predicts the outcome:puttygen's own output is affected the same way, since it writes the same signed mpint (put_mp_ssh2).Suggested fix
That makes the PuTTY parser match what the OpenSSH parser already does at line 213.
Padcovers the other direction (a short scalar with genuine leading zeros), so trim-then-pad handles both ends.A regression fixture needs a
.ppkwhose scalar has the high bit set, since the current one does not exercise the path. Happy to supply one if useful.