Skip to content

Commit 3e12c96

Browse files
Add support for OpenSSH certificates (#1498)
Co-authored-by: cedricMicrovision <[email protected]>
1 parent fbedaab commit 3e12c96

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

41 files changed

+1632
-70
lines changed

docfx/examples.md

+28-1
Original file line numberDiff line numberDiff line change
@@ -59,12 +59,39 @@ using (var client = new SshClient("sftp.foo.com", "guest", "pwd"))
5959
{
6060
client.HostKeyReceived += (sender, e) =>
6161
{
62-
e.CanTrust = expectedFingerPrint.Equals(e.FingerPrintSHA256);
62+
e.CanTrust = e.FingerPrintSHA256 == expectedFingerPrint;
6363
};
6464
client.Connect();
6565
}
6666
```
6767

68+
When expecting the server to present a certificate signed by a trusted certificate authority:
69+
70+
```cs
71+
string expectedCAFingerPrint = "tF3DRTUXtYFZ5Yz0SBOrEbixHaCifHmNVK6FtptXZVM";
72+
73+
using (var client = new SshClient("sftp.foo.com", "guest", "pwd"))
74+
{
75+
client.HostKeyReceived += (sender, e) =>
76+
{
77+
e.CanTrust = e.Certificate?.CertificateAuthorityKeyFingerPrint == expectedCAFingerPrint;
78+
};
79+
client.Connect();
80+
}
81+
```
82+
83+
### Authenticating with a user certificate
84+
85+
When you have a certificate for your key which is signed by a certificate authority that the server trusts:
86+
87+
```cs
88+
using (var privateKeyFile = new PrivateKeyFile("path/to/my/key", passPhrase: null, "path/to/my/certificate.pub"))
89+
using (var client = new SshClient("sftp.foo.com", "guest", privateKeyFile))
90+
{
91+
client.Connect();
92+
}
93+
```
94+
6895
### Open a Shell
6996

7097
```cs

src/Renci.SshNet/Common/HostKeyEventArgs.cs

+14-2
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
using System;
1+
#nullable enable
2+
using System;
23

34
using Renci.SshNet.Abstractions;
45
using Renci.SshNet.Security;
@@ -83,6 +84,12 @@ public string FingerPrintMD5
8384
/// </value>
8485
public int KeyLength { get; private set; }
8586

87+
/// <summary>
88+
/// Gets the certificate presented by the host, or <see langword="null"/> if the host
89+
/// did not present a certificate.
90+
/// </summary>
91+
public Certificate? Certificate { get; }
92+
8693
/// <summary>
8794
/// Initializes a new instance of the <see cref="HostKeyEventArgs"/> class.
8895
/// </summary>
@@ -93,7 +100,7 @@ public HostKeyEventArgs(KeyHostAlgorithm host)
93100
ThrowHelper.ThrowIfNull(host);
94101

95102
CanTrust = true;
96-
HostKey = host.Data;
103+
HostKey = host.KeyData.GetBytes();
97104
HostKeyName = host.Name;
98105
KeyLength = host.Key.KeyLength;
99106

@@ -107,6 +114,11 @@ public HostKeyEventArgs(KeyHostAlgorithm host)
107114
return BitConverter.ToString(FingerPrint).Replace('-', ':').ToLowerInvariant();
108115
#pragma warning restore CA1308 // Normalize strings to uppercase
109116
});
117+
118+
if (host is CertificateHostAlgorithm certificateAlg)
119+
{
120+
Certificate = certificateAlg.Certificate;
121+
}
110122
}
111123
}
112124
}

src/Renci.SshNet/ConnectionInfo.cs

+18-11
Original file line numberDiff line numberDiff line change
@@ -387,19 +387,26 @@ public ConnectionInfo(string host, int port, string username, ProxyTypes proxyTy
387387
{ "[email protected]", new HashInfo(20*8, key => new HMACSHA1(key), isEncryptThenMAC: true) },
388388
};
389389

390-
HostKeyAlgorithms = new Dictionary<string, Func<byte[], KeyHostAlgorithm>>
391-
{
392-
{ "ssh-ed25519", data => new KeyHostAlgorithm("ssh-ed25519", new ED25519Key(new SshKeyData(data))) },
393-
{ "ecdsa-sha2-nistp256", data => new KeyHostAlgorithm("ecdsa-sha2-nistp256", new EcdsaKey(new SshKeyData(data))) },
394-
{ "ecdsa-sha2-nistp384", data => new KeyHostAlgorithm("ecdsa-sha2-nistp384", new EcdsaKey(new SshKeyData(data))) },
395-
{ "ecdsa-sha2-nistp521", data => new KeyHostAlgorithm("ecdsa-sha2-nistp521", new EcdsaKey(new SshKeyData(data))) },
396390
#pragma warning disable SA1107 // Code should not contain multiple statements on one line
397-
{ "rsa-sha2-512", data => { var key = new RsaKey(new SshKeyData(data)); return new KeyHostAlgorithm("rsa-sha2-512", key, new RsaDigitalSignature(key, HashAlgorithmName.SHA512)); } },
398-
{ "rsa-sha2-256", data => { var key = new RsaKey(new SshKeyData(data)); return new KeyHostAlgorithm("rsa-sha2-256", key, new RsaDigitalSignature(key, HashAlgorithmName.SHA256)); } },
391+
var hostAlgs = new Dictionary<string, Func<byte[], KeyHostAlgorithm>>();
392+
hostAlgs.Add("[email protected]", data => { var cert = new Certificate(data); return new CertificateHostAlgorithm("[email protected]", cert, hostAlgs); });
393+
hostAlgs.Add("[email protected]", data => { var cert = new Certificate(data); return new CertificateHostAlgorithm("[email protected]", cert, hostAlgs); });
394+
hostAlgs.Add("[email protected]", data => { var cert = new Certificate(data); return new CertificateHostAlgorithm("[email protected]", cert, hostAlgs); });
395+
hostAlgs.Add("[email protected]", data => { var cert = new Certificate(data); return new CertificateHostAlgorithm("[email protected]", cert, hostAlgs); });
396+
hostAlgs.Add("[email protected]", data => { var cert = new Certificate(data); return new CertificateHostAlgorithm("[email protected]", cert, new RsaDigitalSignature((RsaKey)cert.Key, HashAlgorithmName.SHA512), hostAlgs); });
397+
hostAlgs.Add("[email protected]", data => { var cert = new Certificate(data); return new CertificateHostAlgorithm("[email protected]", cert, new RsaDigitalSignature((RsaKey)cert.Key, HashAlgorithmName.SHA256), hostAlgs); });
398+
hostAlgs.Add("[email protected]", data => { var cert = new Certificate(data); return new CertificateHostAlgorithm("[email protected]", cert, hostAlgs); });
399+
hostAlgs.Add("[email protected]", data => { var cert = new Certificate(data); return new CertificateHostAlgorithm("[email protected]", cert, hostAlgs); });
400+
hostAlgs.Add("ssh-ed25519", data => new KeyHostAlgorithm("ssh-ed25519", new ED25519Key(new SshKeyData(data))));
401+
hostAlgs.Add("ecdsa-sha2-nistp256", data => new KeyHostAlgorithm("ecdsa-sha2-nistp256", new EcdsaKey(new SshKeyData(data))));
402+
hostAlgs.Add("ecdsa-sha2-nistp384", data => new KeyHostAlgorithm("ecdsa-sha2-nistp384", new EcdsaKey(new SshKeyData(data))));
403+
hostAlgs.Add("ecdsa-sha2-nistp521", data => new KeyHostAlgorithm("ecdsa-sha2-nistp521", new EcdsaKey(new SshKeyData(data))));
404+
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)); });
405+
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)); });
406+
hostAlgs.Add("ssh-rsa", data => new KeyHostAlgorithm("ssh-rsa", new RsaKey(new SshKeyData(data))));
407+
hostAlgs.Add("ssh-dss", data => new KeyHostAlgorithm("ssh-dss", new DsaKey(new SshKeyData(data))));
399408
#pragma warning restore SA1107 // Code should not contain multiple statements on one line
400-
{ "ssh-rsa", data => new KeyHostAlgorithm("ssh-rsa", new RsaKey(new SshKeyData(data))) },
401-
{ "ssh-dss", data => new KeyHostAlgorithm("ssh-dss", new DsaKey(new SshKeyData(data))) },
402-
};
409+
HostKeyAlgorithms = hostAlgs;
403410

404411
CompressionAlgorithms = new Dictionary<string, Func<Compressor>>
405412
{

src/Renci.SshNet/PrivateKeyFile.cs

+116-4
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
using System.Formats.Asn1;
77
using System.Globalization;
88
using System.IO;
9+
using System.Linq;
910
using System.Numerics;
1011
using System.Security.Cryptography;
1112
using System.Text;
@@ -119,15 +120,20 @@ namespace Renci.SshNet
119120
public partial class PrivateKeyFile : IPrivateKeySource, IDisposable
120121
{
121122
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> *-+";
123+
private const string CertificatePattern = @"(?<type>[-\w]+@openssh\.com)\s(?<data>[a-zA-Z0-9\/+=]*)(\s+(?<comment>.*))?";
122124

123125
#if NET7_0_OR_GREATER
124126
private static readonly Regex PrivateKeyRegex = GetPrivateKeyRegex();
127+
private static readonly Regex CertificateRegex = GetCertificateRegex();
125128

126129
[GeneratedRegex(PrivateKeyPattern, RegexOptions.Multiline | RegexOptions.ExplicitCapture)]
127130
private static partial Regex GetPrivateKeyRegex();
131+
132+
[GeneratedRegex(CertificatePattern, RegexOptions.ExplicitCapture)]
133+
private static partial Regex GetCertificateRegex();
128134
#else
129-
private static readonly Regex PrivateKeyRegex = new Regex(PrivateKeyPattern,
130-
RegexOptions.Compiled | RegexOptions.Multiline | RegexOptions.ExplicitCapture);
135+
private static readonly Regex PrivateKeyRegex = new Regex(PrivateKeyPattern, RegexOptions.Compiled | RegexOptions.Multiline | RegexOptions.ExplicitCapture);
136+
private static readonly Regex CertificateRegex = new Regex(CertificatePattern, RegexOptions.Compiled | RegexOptions.ExplicitCapture);
131137
#endif
132138

133139
private readonly List<HostAlgorithm> _hostAlgorithms = new List<HostAlgorithm>();
@@ -156,6 +162,13 @@ public Key Key
156162
}
157163
}
158164

165+
/// <summary>
166+
/// Gets the public key certificate associated with this key,
167+
/// or <see langword="null"/> if no certificate data
168+
/// has been passed to the constructor.
169+
/// </summary>
170+
public Certificate? Certificate { get; private set; }
171+
159172
/// <summary>
160173
/// Initializes a new instance of the <see cref="PrivateKeyFile"/> class.
161174
/// </summary>
@@ -173,7 +186,7 @@ public PrivateKeyFile(Key key)
173186
/// </summary>
174187
/// <param name="privateKey">The private key.</param>
175188
public PrivateKeyFile(Stream privateKey)
176-
: this(privateKey, passPhrase: null)
189+
: this(privateKey, passPhrase: null, certificate: null)
177190
{
178191
}
179192

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

@@ -200,6 +213,18 @@ public PrivateKeyFile(string fileName)
200213
/// This method calls <see cref="File.Open(string, FileMode)"/> internally, this method does not catch exceptions from <see cref="File.Open(string, FileMode)"/>.
201214
/// </remarks>
202215
public PrivateKeyFile(string fileName, string? passPhrase)
216+
: this(fileName, passPhrase, certificateFileName: null)
217+
{
218+
}
219+
220+
/// <summary>
221+
/// Initializes a new instance of the <see cref="PrivateKeyFile"/> class.
222+
/// </summary>
223+
/// <param name="fileName">The path of the private key file.</param>
224+
/// <param name="passPhrase">The pass phrase for the private key.</param>
225+
/// <param name="certificateFileName">The path of a certificate file which certifies the private key.</param>
226+
/// <exception cref="ArgumentNullException"><paramref name="fileName"/> is <see langword="null"/>.</exception>
227+
public PrivateKeyFile(string fileName, string? passPhrase, string? certificateFileName)
203228
{
204229
ThrowHelper.ThrowIfNull(fileName);
205230

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

236+
if (certificateFileName is not null)
237+
{
238+
using (var certificateFile = File.OpenRead(certificateFileName))
239+
{
240+
OpenCertificate(certificateFile);
241+
}
242+
243+
Debug.Assert(Certificate is not null, $"{nameof(Certificate)} is null.");
244+
}
245+
211246
Debug.Assert(Key is not null, $"{nameof(Key)} is null.");
212247
Debug.Assert(HostKeyAlgorithms.Count > 0, $"{nameof(HostKeyAlgorithms)} is not set.");
213248
}
@@ -219,11 +254,29 @@ public PrivateKeyFile(string fileName, string? passPhrase)
219254
/// <param name="passPhrase">The pass phrase.</param>
220255
/// <exception cref="ArgumentNullException"><paramref name="privateKey"/> is <see langword="null"/>.</exception>
221256
public PrivateKeyFile(Stream privateKey, string? passPhrase)
257+
: this(privateKey, passPhrase, certificate: null)
258+
{
259+
}
260+
261+
/// <summary>
262+
/// Initializes a new instance of the <see cref="PrivateKeyFile"/> class.
263+
/// </summary>
264+
/// <param name="privateKey">The private key.</param>
265+
/// <param name="passPhrase">The pass phrase for the private key.</param>
266+
/// <param name="certificate">A certificate which certifies the private key.</param>
267+
public PrivateKeyFile(Stream privateKey, string? passPhrase, Stream? certificate)
222268
{
223269
ThrowHelper.ThrowIfNull(privateKey);
224270

225271
Open(privateKey, passPhrase);
226272

273+
if (certificate is not null)
274+
{
275+
OpenCertificate(certificate);
276+
277+
Debug.Assert(Certificate is not null, $"{nameof(Certificate)} is null.");
278+
}
279+
227280
Debug.Assert(Key is not null, $"{nameof(Key)} is null.");
228281
Debug.Assert(HostKeyAlgorithms.Count > 0, $"{nameof(HostKeyAlgorithms)} is not set.");
229282
}
@@ -854,6 +907,65 @@ private static Key ParseOpenSslPkcs8PrivateKey(PrivateKeyInfo privateKeyInfo)
854907
throw new SshException(string.Format(CultureInfo.InvariantCulture, "Private key algorithm \"{0}\" is not supported.", algorithmOid));
855908
}
856909

910+
/// <summary>
911+
/// Opens the specified certificate.
912+
/// </summary>
913+
/// <param name="certificate">The certificate.</param>
914+
private void OpenCertificate(Stream certificate)
915+
{
916+
Debug.Assert(certificate is not null, "Should have validated not-null in the constructor.");
917+
918+
Match certificateMatch;
919+
920+
using (var sr = new StreamReader(certificate))
921+
{
922+
var text = sr.ReadToEnd();
923+
certificateMatch = CertificateRegex.Match(text);
924+
}
925+
926+
if (!certificateMatch.Success)
927+
{
928+
throw new SshException("Invalid certificate file.");
929+
}
930+
931+
var data = certificateMatch.Result("${data}");
932+
933+
Certificate = new Certificate(Convert.FromBase64String(data));
934+
935+
Debug.Assert(Key is not null, $"{nameof(Key)} should have been initialised already.");
936+
937+
if (!Certificate.Key.Public.SequenceEqual(Key.Public))
938+
{
939+
throw new ArgumentException("The supplied certificate does not certify the supplied key.");
940+
}
941+
942+
if (Key is RsaKey rsaKey)
943+
{
944+
Debug.Assert(Certificate.Key is RsaKey,
945+
$"Expected {nameof(Certificate)}.{nameof(Certificate.Key)} to be {nameof(RsaKey)} but was {Certificate.Key?.GetType()}");
946+
947+
_hostAlgorithms.Insert(0, new CertificateHostAlgorithm("[email protected]", Key, Certificate));
948+
949+
#pragma warning disable CA2000 // Dispose objects before losing scope
950+
_hostAlgorithms.Insert(0, new CertificateHostAlgorithm(
951+
952+
Key,
953+
Certificate,
954+
new RsaDigitalSignature(rsaKey, HashAlgorithmName.SHA256)));
955+
956+
_hostAlgorithms.Insert(0, new CertificateHostAlgorithm(
957+
958+
Key,
959+
Certificate,
960+
new RsaDigitalSignature(rsaKey, HashAlgorithmName.SHA512)));
961+
#pragma warning restore CA2000 // Dispose objects before losing scope
962+
}
963+
else
964+
{
965+
_hostAlgorithms.Insert(0, new CertificateHostAlgorithm(Certificate.Name, Key, Certificate));
966+
}
967+
}
968+
857969
/// <summary>
858970
/// Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources.
859971
/// </summary>

0 commit comments

Comments
 (0)