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 OpenSSH certificates #1498

Merged
merged 4 commits into from
Oct 19, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 28 additions & 1 deletion docfx/examples.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,12 +59,39 @@ using (var client = new SshClient("sftp.foo.com", "guest", "pwd"))
{
client.HostKeyReceived += (sender, e) =>
{
e.CanTrust = expectedFingerPrint.Equals(e.FingerPrintSHA256);
e.CanTrust = e.FingerPrintSHA256 == expectedFingerPrint;
};
client.Connect();
}
```

When expecting the server to present a certificate signed by a trusted certificate authority:

```cs
string expectedCAFingerPrint = "tF3DRTUXtYFZ5Yz0SBOrEbixHaCifHmNVK6FtptXZVM";

using (var client = new SshClient("sftp.foo.com", "guest", "pwd"))
{
client.HostKeyReceived += (sender, e) =>
{
e.CanTrust = e.Certificate?.CertificateAuthorityKeyFingerPrint == expectedCAFingerPrint;
};
client.Connect();
}
```

### Authenticating with a user certificate

When you have a certificate for your key which is signed by a certificate authority that the server trusts:

```cs
using (var privateKeyFile = new PrivateKeyFile("path/to/my/key", passPhrase: null, "path/to/my/certificate.pub"))
using (var client = new SshClient("sftp.foo.com", "guest", privateKeyFile))
{
client.Connect();
}
```

### Open a Shell

```cs
Expand Down
16 changes: 14 additions & 2 deletions src/Renci.SshNet/Common/HostKeyEventArgs.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using System;
#nullable enable
using System;

using Renci.SshNet.Abstractions;
using Renci.SshNet.Security;
Expand Down Expand Up @@ -83,6 +84,12 @@ public string FingerPrintMD5
/// </value>
public int KeyLength { get; private set; }

/// <summary>
/// Gets the certificate presented by the host, or <see langword="null"/> if the host
/// did not present a certificate.
/// </summary>
public Certificate? Certificate { get; }

/// <summary>
/// Initializes a new instance of the <see cref="HostKeyEventArgs"/> class.
/// </summary>
Expand All @@ -93,7 +100,7 @@ public HostKeyEventArgs(KeyHostAlgorithm host)
ThrowHelper.ThrowIfNull(host);

CanTrust = true;
HostKey = host.Data;
HostKey = host.KeyData.GetBytes();
HostKeyName = host.Name;
KeyLength = host.Key.KeyLength;

Expand All @@ -107,6 +114,11 @@ public HostKeyEventArgs(KeyHostAlgorithm host)
return BitConverter.ToString(FingerPrint).Replace('-', ':').ToLowerInvariant();
#pragma warning restore CA1308 // Normalize strings to uppercase
});

if (host is CertificateHostAlgorithm certificateAlg)
{
Certificate = certificateAlg.Certificate;
}
}
}
}
29 changes: 18 additions & 11 deletions src/Renci.SshNet/ConnectionInfo.cs
Original file line number Diff line number Diff line change
Expand Up @@ -387,19 +387,26 @@ public ConnectionInfo(string host, int port, string username, ProxyTypes proxyTy
{ "[email protected]", new HashInfo(20*8, key => new HMACSHA1(key), isEncryptThenMAC: true) },
};

HostKeyAlgorithms = new Dictionary<string, Func<byte[], KeyHostAlgorithm>>
{
{ "ssh-ed25519", data => new KeyHostAlgorithm("ssh-ed25519", new ED25519Key(new SshKeyData(data))) },
{ "ecdsa-sha2-nistp256", data => new KeyHostAlgorithm("ecdsa-sha2-nistp256", new EcdsaKey(new SshKeyData(data))) },
{ "ecdsa-sha2-nistp384", data => new KeyHostAlgorithm("ecdsa-sha2-nistp384", new EcdsaKey(new SshKeyData(data))) },
{ "ecdsa-sha2-nistp521", data => new KeyHostAlgorithm("ecdsa-sha2-nistp521", new EcdsaKey(new SshKeyData(data))) },
#pragma warning disable SA1107 // Code should not contain multiple statements on one line
{ "rsa-sha2-512", data => { var key = new RsaKey(new SshKeyData(data)); return new KeyHostAlgorithm("rsa-sha2-512", key, new RsaDigitalSignature(key, HashAlgorithmName.SHA512)); } },
{ "rsa-sha2-256", data => { var key = new RsaKey(new SshKeyData(data)); return new KeyHostAlgorithm("rsa-sha2-256", key, new RsaDigitalSignature(key, HashAlgorithmName.SHA256)); } },
var hostAlgs = new Dictionary<string, Func<byte[], KeyHostAlgorithm>>();
hostAlgs.Add("[email protected]", data => { var cert = new Certificate(data); return new CertificateHostAlgorithm("[email protected]", cert, hostAlgs); });
Comment on lines +391 to +392
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

In order to make the host validation example work, the certificate algorithms should be listed first (so that the server knows to send its certificate). Doing this comes with a risk in case there is an unknown bug in the new code. I think the test coverage is good enough, but we could list the new algorithms last to avoid this risk.

hostAlgs.Add("[email protected]", data => { var cert = new Certificate(data); return new CertificateHostAlgorithm("[email protected]", cert, hostAlgs); });
hostAlgs.Add("[email protected]", data => { var cert = new Certificate(data); return new CertificateHostAlgorithm("[email protected]", cert, hostAlgs); });
hostAlgs.Add("[email protected]", data => { var cert = new Certificate(data); return new CertificateHostAlgorithm("[email protected]", cert, hostAlgs); });
hostAlgs.Add("[email protected]", data => { var cert = new Certificate(data); return new CertificateHostAlgorithm("[email protected]", cert, new RsaDigitalSignature((RsaKey)cert.Key, HashAlgorithmName.SHA512), hostAlgs); });
hostAlgs.Add("[email protected]", data => { var cert = new Certificate(data); return new CertificateHostAlgorithm("[email protected]", cert, new RsaDigitalSignature((RsaKey)cert.Key, HashAlgorithmName.SHA256), hostAlgs); });
hostAlgs.Add("[email protected]", data => { var cert = new Certificate(data); return new CertificateHostAlgorithm("[email protected]", cert, hostAlgs); });
hostAlgs.Add("[email protected]", data => { var cert = new Certificate(data); return new CertificateHostAlgorithm("[email protected]", cert, hostAlgs); });
hostAlgs.Add("ssh-ed25519", data => new KeyHostAlgorithm("ssh-ed25519", new ED25519Key(new SshKeyData(data))));
hostAlgs.Add("ecdsa-sha2-nistp256", data => new KeyHostAlgorithm("ecdsa-sha2-nistp256", new EcdsaKey(new SshKeyData(data))));
hostAlgs.Add("ecdsa-sha2-nistp384", data => new KeyHostAlgorithm("ecdsa-sha2-nistp384", new EcdsaKey(new SshKeyData(data))));
hostAlgs.Add("ecdsa-sha2-nistp521", data => new KeyHostAlgorithm("ecdsa-sha2-nistp521", new EcdsaKey(new SshKeyData(data))));
hostAlgs.Add("rsa-sha2-512", data => { var key = new RsaKey(new SshKeyData(data)); return new KeyHostAlgorithm("rsa-sha2-512", key, new RsaDigitalSignature(key, HashAlgorithmName.SHA512)); });
hostAlgs.Add("rsa-sha2-256", data => { var key = new RsaKey(new SshKeyData(data)); return new KeyHostAlgorithm("rsa-sha2-256", key, new RsaDigitalSignature(key, HashAlgorithmName.SHA256)); });
hostAlgs.Add("ssh-rsa", data => new KeyHostAlgorithm("ssh-rsa", new RsaKey(new SshKeyData(data))));
hostAlgs.Add("ssh-dss", data => new KeyHostAlgorithm("ssh-dss", new DsaKey(new SshKeyData(data))));
#pragma warning restore SA1107 // Code should not contain multiple statements on one line
{ "ssh-rsa", data => new KeyHostAlgorithm("ssh-rsa", new RsaKey(new SshKeyData(data))) },
{ "ssh-dss", data => new KeyHostAlgorithm("ssh-dss", new DsaKey(new SshKeyData(data))) },
};
HostKeyAlgorithms = hostAlgs;

CompressionAlgorithms = new Dictionary<string, Func<Compressor>>
{
Expand Down
120 changes: 116 additions & 4 deletions src/Renci.SshNet/PrivateKeyFile.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
using System.Formats.Asn1;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Numerics;
using System.Security.Cryptography;
using System.Text;
Expand Down Expand Up @@ -119,15 +120,20 @@ namespace Renci.SshNet
public partial class PrivateKeyFile : IPrivateKeySource, IDisposable
{
private const string PrivateKeyPattern = @"^-+ *BEGIN (?<keyName>\w+( \w+)*) *-+\r?\n((Proc-Type: 4,ENCRYPTED\r?\nDEK-Info: (?<cipherName>[A-Z0-9-]+),(?<salt>[A-F0-9]+)\r?\n\r?\n)|(Comment: ""?[^\r\n]*""?\r?\n))?(?<data>([a-zA-Z0-9/+=]{1,80}\r?\n)+)(\r?\n)?-+ *END \k<keyName> *-+";
private const string CertificatePattern = @"(?<type>[-\w]+@openssh\.com)\s(?<data>[a-zA-Z0-9\/+=]*)(\s+(?<comment>.*))?";

#if NET7_0_OR_GREATER
private static readonly Regex PrivateKeyRegex = GetPrivateKeyRegex();
private static readonly Regex CertificateRegex = GetCertificateRegex();

[GeneratedRegex(PrivateKeyPattern, RegexOptions.Multiline | RegexOptions.ExplicitCapture)]
private static partial Regex GetPrivateKeyRegex();

[GeneratedRegex(CertificatePattern, RegexOptions.ExplicitCapture)]
private static partial Regex GetCertificateRegex();
#else
private static readonly Regex PrivateKeyRegex = new Regex(PrivateKeyPattern,
RegexOptions.Compiled | RegexOptions.Multiline | RegexOptions.ExplicitCapture);
private static readonly Regex PrivateKeyRegex = new Regex(PrivateKeyPattern, RegexOptions.Compiled | RegexOptions.Multiline | RegexOptions.ExplicitCapture);
private static readonly Regex CertificateRegex = new Regex(CertificatePattern, RegexOptions.Compiled | RegexOptions.ExplicitCapture);
#endif

private readonly List<HostAlgorithm> _hostAlgorithms = new List<HostAlgorithm>();
Expand Down Expand Up @@ -156,6 +162,13 @@ public Key Key
}
}

/// <summary>
/// Gets the public key certificate associated with this key,
/// or <see langword="null"/> if no certificate data
/// has been passed to the constructor.
/// </summary>
public Certificate? Certificate { get; private set; }

/// <summary>
/// Initializes a new instance of the <see cref="PrivateKeyFile"/> class.
/// </summary>
Expand All @@ -173,7 +186,7 @@ public PrivateKeyFile(Key key)
/// </summary>
/// <param name="privateKey">The private key.</param>
public PrivateKeyFile(Stream privateKey)
: this(privateKey, passPhrase: null)
: this(privateKey, passPhrase: null, certificate: null)
{
}

Expand All @@ -186,7 +199,7 @@ public PrivateKeyFile(Stream privateKey)
/// This method calls <see cref="File.Open(string, FileMode)"/> internally, this method does not catch exceptions from <see cref="File.Open(string, FileMode)"/>.
/// </remarks>
public PrivateKeyFile(string fileName)
: this(fileName, passPhrase: null)
: this(fileName, passPhrase: null, certificateFileName: null)
{
}

Expand All @@ -200,6 +213,18 @@ public PrivateKeyFile(string fileName)
/// This method calls <see cref="File.Open(string, FileMode)"/> internally, this method does not catch exceptions from <see cref="File.Open(string, FileMode)"/>.
/// </remarks>
public PrivateKeyFile(string fileName, string? passPhrase)
: this(fileName, passPhrase, certificateFileName: null)
{
}

/// <summary>
/// Initializes a new instance of the <see cref="PrivateKeyFile"/> class.
/// </summary>
/// <param name="fileName">The path of the private key file.</param>
/// <param name="passPhrase">The pass phrase for the private key.</param>
/// <param name="certificateFileName">The path of a certificate file which certifies the private key.</param>
/// <exception cref="ArgumentNullException"><paramref name="fileName"/> is <see langword="null"/>.</exception>
public PrivateKeyFile(string fileName, string? passPhrase, string? certificateFileName)
{
ThrowHelper.ThrowIfNull(fileName);

Expand All @@ -208,6 +233,16 @@ public PrivateKeyFile(string fileName, string? passPhrase)
Open(keyFile, passPhrase);
}

if (certificateFileName is not null)
{
using (var certificateFile = File.OpenRead(certificateFileName))
{
OpenCertificate(certificateFile);
}

Debug.Assert(Certificate is not null, $"{nameof(Certificate)} is null.");
}

Debug.Assert(Key is not null, $"{nameof(Key)} is null.");
Debug.Assert(HostKeyAlgorithms.Count > 0, $"{nameof(HostKeyAlgorithms)} is not set.");
}
Expand All @@ -219,11 +254,29 @@ public PrivateKeyFile(string fileName, string? passPhrase)
/// <param name="passPhrase">The pass phrase.</param>
/// <exception cref="ArgumentNullException"><paramref name="privateKey"/> is <see langword="null"/>.</exception>
public PrivateKeyFile(Stream privateKey, string? passPhrase)
: this(privateKey, passPhrase, certificate: null)
{
}

/// <summary>
/// Initializes a new instance of the <see cref="PrivateKeyFile"/> class.
/// </summary>
/// <param name="privateKey">The private key.</param>
/// <param name="passPhrase">The pass phrase for the private key.</param>
/// <param name="certificate">A certificate which certifies the private key.</param>
public PrivateKeyFile(Stream privateKey, string? passPhrase, Stream? certificate)
{
ThrowHelper.ThrowIfNull(privateKey);

Open(privateKey, passPhrase);

if (certificate is not null)
{
OpenCertificate(certificate);

Debug.Assert(Certificate is not null, $"{nameof(Certificate)} is null.");
}

Debug.Assert(Key is not null, $"{nameof(Key)} is null.");
Debug.Assert(HostKeyAlgorithms.Count > 0, $"{nameof(HostKeyAlgorithms)} is not set.");
}
Expand Down Expand Up @@ -854,6 +907,65 @@ private static Key ParseOpenSslPkcs8PrivateKey(PrivateKeyInfo privateKeyInfo)
throw new SshException(string.Format(CultureInfo.InvariantCulture, "Private key algorithm \"{0}\" is not supported.", algorithmOid));
}

/// <summary>
/// Opens the specified certificate.
/// </summary>
/// <param name="certificate">The certificate.</param>
private void OpenCertificate(Stream certificate)
{
Debug.Assert(certificate is not null, "Should have validated not-null in the constructor.");

Match certificateMatch;

using (var sr = new StreamReader(certificate))
{
var text = sr.ReadToEnd();
certificateMatch = CertificateRegex.Match(text);
}

if (!certificateMatch.Success)
{
throw new SshException("Invalid certificate file.");
}

var data = certificateMatch.Result("${data}");

Certificate = new Certificate(Convert.FromBase64String(data));

Debug.Assert(Key is not null, $"{nameof(Key)} should have been initialised already.");

if (!Certificate.Key.Public.SequenceEqual(Key.Public))
{
throw new ArgumentException("The supplied certificate does not certify the supplied key.");
}

if (Key is RsaKey rsaKey)
{
Debug.Assert(Certificate.Key is RsaKey,
$"Expected {nameof(Certificate)}.{nameof(Certificate.Key)} to be {nameof(RsaKey)} but was {Certificate.Key?.GetType()}");

_hostAlgorithms.Insert(0, new CertificateHostAlgorithm("[email protected]", Key, Certificate));

#pragma warning disable CA2000 // Dispose objects before losing scope
_hostAlgorithms.Insert(0, new CertificateHostAlgorithm(
"[email protected]",
Key,
Certificate,
new RsaDigitalSignature(rsaKey, HashAlgorithmName.SHA256)));

_hostAlgorithms.Insert(0, new CertificateHostAlgorithm(
"[email protected]",
Key,
Certificate,
new RsaDigitalSignature(rsaKey, HashAlgorithmName.SHA512)));
#pragma warning restore CA2000 // Dispose objects before losing scope
}
else
{
_hostAlgorithms.Insert(0, new CertificateHostAlgorithm(Certificate.Name, Key, Certificate));
}
}

/// <summary>
/// Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources.
/// </summary>
Expand Down
Loading