Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for OpenSSL PKCS#8 private key format #1496

Merged
merged 9 commits into from
Sep 21, 2024

Conversation

scott-xu
Copy link
Collaborator

@scott-xu scott-xu commented Sep 16, 2024

This PR adds support for PKCS#8 private key format (in practice, OpenSSL is used to generate the keys)

There are several options to add support for PKCS#8 private key format:

  1. Use System.Security.Cryptography.Pkcs. It is from Microsoft and it supports exactly the same targets frameworks as SSH.NET. However, it is shipped as NuGet only which means we need to add it as a dependency for all target frameworks.
  2. Call ImportPkcs8PrivateKey or ImportFromEncryptedPem from relevant concrete class. Both methods applied to netstandard2.1+ and Core 3.0+. However, I couldn't find a way to detect the key type before call the methods, for example a static method to load PKCS#8 private key and returns BCL's AsymmetricAlgorithm.
  3. Use BouncyCastle's PemReader to load the private key and return BouncyCastle's AsymmetricKeyParameter. Or use BouncyCastle's underlying PrivateKeyFactory to CreateKey / DecryptKey which also returns BouncyCastle's AsymmetricKeyParameter. Then we need to bridge BouncyCastle's AsymmetricKeyParameter with SSH.NET's Key. This option may add some complexity to map types and unnecessary memory allocations.
  4. My finally proposal is to use BouncyCastle to decrypt the PCKS#8 private key, read the PrivateKeyInfo Der structure and then read the parameters and key manually with AsnReader and finally construct the SSH.NET relevant concrete Key class.
    With this option, there's no extra NuGet dependency and performance is balanced.

I also considered using Impl pattern for PrivateKeyFile class, but haven't had time to try. We can continue evolving this PR or create a new PR if needs be.

We can also consider dropping support for OpenSSL traditional private key format and drop ssh.com private key format as they are old and weak.

Fix #1090

Copy link
Collaborator

@Rob-Hague Rob-Hague left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The chosen approach seems reasonable. I don't think see why any of DsaKey, EcdsaKey, RsaKey types need to change (in particular, breaking changes)

/// Initializes a new instance of the <see cref="DsaKey"/> class.
/// </summary>
/// <param name="privateKeyData">DER encoded private key data.</param>
public DsaKey(byte[] privateKeyData)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is it necessary to remove this?

Same applies to the other key constructors

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I remove it because there could be other DER data structures. The current Key constructors with byte array parameter assume the data is DER encoded OpenSSL traditional format which is not always true. It could be PKCS#8 format as well.

In practice, most package consumers are not necessarily call Key constructors directly. It might not bring huge impacts.

Anyway, to gracefully deprecate the constructors with byte array parameter, we can apply Obsolete attribute to the constructors and remove them in the future release. What do you think? @Rob-Hague

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am sorry, I do not think there is a reason to break the existing contract.

I agree there is probably not many people using these constructors, and if we were starting from scratch we might do it differently. And although we make breaking changes practically every release, I think we can justify each of them. I do not believe this one can be justified.

As library developers, when we make a breaking change we are telling the user: "we expect you to bear the pain for our work". As a user, I can sometimes accept that when the user experience is improved and when it is easy to react to the change. Otherwise I find it annoying and frankly inconsiderate from the developers.

Whether we make this change or not, we experience no pain, so we should not cause any pain to users. We can instead improve things here by clarifying in the documentation that this constructor expects an OpenSSL format.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry I didn't explain well.
The current Key constructors with byte array parameter assume the data is DER encoded OpenSSL traditional PKCS#1 format.
OpenSSL use PKCS#8 by default. It only uses PKCS#1 when option "-traditional" is specified.
The newer "genpkey" command even doesn't show "-traditional" option in the guide.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I understand the situation, I just don't think it is worth deleting the constructor or changing its behaviour, unless you can somehow detect which format it is using while reading. If not, then the constructor should stay the same

Copy link
Collaborator Author

@scott-xu scott-xu Sep 17, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

After some more thinking, I'm leaning towards to just delete those constructors.

We removed old target frameworks. People who are using old frameworks should target to new frameworks. See #1109
We removed legacy algorithms. People who are using legacy algorithms should use new algorithms. See #1442

This change only affects people who are directly calling the constructor with PKCS#1 data. They should use PKCS#8 data instead.

Note: People who are using PrivateKeyFile are not affected. I believe most people are using PrivateKeyFile instead of each Key class.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This improvement does not require changing public API. It brings us zero benefits, and brings detriment to users

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If everyone agrees, then I can add them back. Would you mind sharing your thoughts here? @drieseng @WojciechNagorski @mus65

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree removing it is too much. But imho it should at least be obsoleted since it is ambigious and confusing (.NET 9 recently made a similiar API obsolete). But then there should be an alternative public API people can move to (I assume there is? I haven't checked the whole PR).

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree removing it is too much. But imho it should at least be obsoleted since it is ambigious and confusing (.NET 9 recently made a similiar API obsolete). But then there should be an alternative public API people can move to (I assume there is? I haven't checked the whole PR).

Thanks for sharing the info. I know I'm not alone.

BTW, Microsoft calls their new design "one method, one purpose" which is exactly what I want to apply for method static string GetCurveOid(string curve_s).

@@ -207,34 +206,12 @@ public EcdsaKey(SshKeyData publicKeyData)
/// <summary>
/// Initializes a new instance of the <see cref="EcdsaKey"/> class.
/// </summary>
/// <param name="curve">The curve name.</param>
/// <param name="curve">The curve oid.</param>
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's a nasty break. Let's try to avoid it

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

By reading the DER data, we get the curve oid string directly. It looks strange if we convert curve oid string to curve name in PrivateKeyFile and then convert name back to oid string in Ecdsakey.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, it's strange and unfortunate, but it's the publicly exposed contract, so my other comment applies here too. If you like, you can change it to accept either the name or oid

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We need to either document the breaking change in the release notes, or keep the current behavior but increase the code complexity (accept either name or oid). 😢

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It should be the latter. Does not seem that complex to me

diff --git a/src/Renci.SshNet/Security/Cryptography/EcdsaKey.cs b/src/Renci.SshNet/Security/Cryptography/EcdsaKey.cs
index 70e2e1a9..6f275b20 100644
--- a/src/Renci.SshNet/Security/Cryptography/EcdsaKey.cs
+++ b/src/Renci.SshNet/Security/Cryptography/EcdsaKey.cs
@@ -271,17 +271,20 @@ private static Impl Import(string curve_oid, byte[] publickey, byte[]? privateke

         private static string GetCurveOid(string curve_s)
         {
-            if (string.Equals(curve_s, "nistp256", StringComparison.OrdinalIgnoreCase))
+            if (string.Equals(curve_s, "nistp256", StringComparison.OrdinalIgnoreCase)
+                || curve_s == ECDSA_P256_OID_VALUE)
             {
                 return ECDSA_P256_OID_VALUE;
             }

-            if (string.Equals(curve_s, "nistp384", StringComparison.OrdinalIgnoreCase))
+            if (string.Equals(curve_s, "nistp384", StringComparison.OrdinalIgnoreCase)
+                || curve_s == ECDSA_P384_OID_VALUE)
             {
                 return ECDSA_P384_OID_VALUE;
             }

-            if (string.Equals(curve_s, "nistp521", StringComparison.OrdinalIgnoreCase))
+            if (string.Equals(curve_s, "nistp521", StringComparison.OrdinalIgnoreCase)
+                || curve_s == ECDSA_P521_OID_VALUE)
             {
                 return ECDSA_P521_OID_VALUE;
             }

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sure from coding perspective, it is not complex. It is just the method signature definition. Now the first parameter could be either name or oid. Would it break single responsibility principle?

test/Data/Key.PKCS8.DSA.pub Outdated Show resolved Hide resolved
/// <returns>The <see cref="EcdsaKey" />.</returns>
private static EcdsaKey ParseECPrivateKey_SEC1(byte[] keyData)
{
var keyReader = new AsnReader(keyData, AsnEncodingRules.DER);
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Based on the comment above, should this be

Suggested change
var keyReader = new AsnReader(keyData, AsnEncodingRules.DER);
var keyReader = new AsnReader(keyData, AsnEncodingRules.BER);

?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

DERs are subset of BERs. I think DERs are enough here since the data is generated using DERs?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am reading this part of the comment: "receivers SHOULD be prepared to handle Basic Encoding Rules (BER) and DER"

In my understanding, that means using AsnEncodingRules.BER to match the comment

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actually I'm confused about the doc. Do you know why it says "BER and DER".

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I guess they are trying to be explicit but they do not make it any clearer by doing so

@scott-xu scott-xu marked this pull request as ready for review September 18, 2024 00:18
Copy link
Collaborator

@Rob-Hague Rob-Hague left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please restore the public constructors

Sorry about the conflicts

/// <returns>The <see cref="EcdsaKey" />.</returns>
private static EcdsaKey ParseECPrivateKey_SEC1(byte[] keyData)
{
var keyReader = new AsnReader(keyData, AsnEncodingRules.DER);
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am reading this part of the comment: "receivers SHOULD be prepared to handle Basic Encoding Rules (BER) and DER"

In my understanding, that means using AsnEncodingRules.BER to match the comment

@@ -207,34 +206,12 @@ public EcdsaKey(SshKeyData publicKeyData)
/// <summary>
/// Initializes a new instance of the <see cref="EcdsaKey"/> class.
/// </summary>
/// <param name="curve">The curve name.</param>
/// <param name="curve">The curve oid.</param>
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It should be the latter. Does not seem that complex to me

diff --git a/src/Renci.SshNet/Security/Cryptography/EcdsaKey.cs b/src/Renci.SshNet/Security/Cryptography/EcdsaKey.cs
index 70e2e1a9..6f275b20 100644
--- a/src/Renci.SshNet/Security/Cryptography/EcdsaKey.cs
+++ b/src/Renci.SshNet/Security/Cryptography/EcdsaKey.cs
@@ -271,17 +271,20 @@ private static Impl Import(string curve_oid, byte[] publickey, byte[]? privateke

         private static string GetCurveOid(string curve_s)
         {
-            if (string.Equals(curve_s, "nistp256", StringComparison.OrdinalIgnoreCase))
+            if (string.Equals(curve_s, "nistp256", StringComparison.OrdinalIgnoreCase)
+                || curve_s == ECDSA_P256_OID_VALUE)
             {
                 return ECDSA_P256_OID_VALUE;
             }

-            if (string.Equals(curve_s, "nistp384", StringComparison.OrdinalIgnoreCase))
+            if (string.Equals(curve_s, "nistp384", StringComparison.OrdinalIgnoreCase)
+                || curve_s == ECDSA_P384_OID_VALUE)
             {
                 return ECDSA_P384_OID_VALUE;
             }

-            if (string.Equals(curve_s, "nistp521", StringComparison.OrdinalIgnoreCase))
+            if (string.Equals(curve_s, "nistp521", StringComparison.OrdinalIgnoreCase)
+                || curve_s == ECDSA_P521_OID_VALUE)
             {
                 return ECDSA_P521_OID_VALUE;
             }

/// Initializes a new instance of the <see cref="DsaKey"/> class.
/// </summary>
/// <param name="privateKeyData">DER encoded private key data.</param>
public DsaKey(byte[] privateKeyData)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This improvement does not require changing public API. It brings us zero benefits, and brings detriment to users

@scott-xu scott-xu marked this pull request as draft September 19, 2024 13:12
@scott-xu scott-xu marked this pull request as ready for review September 20, 2024 15:21
Copy link
Collaborator

@Rob-Hague Rob-Hague left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good, thanks for the changes

@Rob-Hague Rob-Hague merged commit 548ef23 into sshnet:develop Sep 21, 2024
1 check passed
@scott-xu
Copy link
Collaborator Author

TBH, I don't like my changes. Life is short. I may leave for a while.
Best wishes for SSH.NET

@Rob-Hague
Copy link
Collaborator

Understood @scott-xu. I know it has not always been smooth sailing, but you make valuable contributions nonetheless. Thanks

@scott-xu scott-xu deleted the privatekey-pkcs8 branch November 21, 2024 15:07
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Support PKCS#8
3 participants