Skip to content

Commit c9f9e3c

Browse files
committed
Support for Ed25519 Host- and Private-Keys
1 parent 90fb62b commit c9f9e3c

13 files changed

+1434
-6
lines changed

THIRD-PARTY-NOTICES.TXT

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,3 +53,21 @@ SHA512
5353
written by Christian Winnerlein (CodesInChaos)
5454
public domain
5555
directly from the specification
56+
57+
License notice for BCrypt
58+
-------------------------
59+
60+
Copyright (c) 2006 Damien Miller <[email protected]>
61+
Copyright (c) 2010 Ryan D. Emerle
62+
63+
Permission to use, copy, modify, and distribute this software for any
64+
purpose with or without fee is hereby granted, provided that the above
65+
copyright notice and this permission notice appear in all copies.
66+
67+
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
68+
WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
69+
MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
70+
ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
71+
WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
72+
ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
73+
OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.

src/Renci.SshNet.NET35/Renci.SshNet.NET35.csproj

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -824,6 +824,9 @@
824824
<Compile Include="..\Renci.SshNet\Security\Cryptography\AsymmetricCipher.cs">
825825
<Link>Security\Cryptography\AsymmetricCipher.cs</Link>
826826
</Compile>
827+
<Compile Include="..\Renci.SshNet\Security\Cryptography\Bcrypt.cs">
828+
<Link>Security\Cryptography\Bcrypt.cs</Link>
829+
</Compile>
827830
<Compile Include="..\Renci.SshNet\Security\Cryptography\BlockCipher.cs">
828831
<Link>Security\Cryptography\BlockCipher.cs</Link>
829832
</Compile>

src/Renci.SshNet.Tests/Classes/PrivateKeyFileTest.cs

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -545,6 +545,34 @@ public void ConstructorWithFileNameAndPassPhraseShouldBeAbleToReadFileThatIsShar
545545
}
546546
}
547547

548+
/// <summary>
549+
/// A test for opening an openssh v1 keyfile where there is no passphrase.
550+
///</summary>
551+
[TestMethod()]
552+
[Owner("bhalbright")]
553+
[TestCategory("PrivateKey")]
554+
public void TestOpenSshV1KeyFileNoPassphrase()
555+
{
556+
using (var stream = GetData("Key.OPENSSH.ED25519.txt"))
557+
{
558+
new PrivateKeyFile(stream);
559+
}
560+
}
561+
562+
/// <summary>
563+
/// A test for opening an openssh v1 keyfile where there is a passphrase.
564+
///</summary>
565+
[TestMethod()]
566+
[Owner("bhalbright")]
567+
[TestCategory("PrivateKey")]
568+
public void TestOpenSshV1KeyFileWithPassphrase()
569+
{
570+
using (var stream = GetData("Key.OPENSSH.ED25519.Encrypted.txt"))
571+
{
572+
new PrivateKeyFile(stream, "password");
573+
}
574+
}
575+
548576
private void SaveStreamToFile(Stream stream, string fileName)
549577
{
550578
var buffer = new byte[4000];
@@ -567,4 +595,4 @@ private string GetTempFileName()
567595
return tempFile;
568596
}
569597
}
570-
}
598+
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
-----BEGIN OPENSSH PRIVATE KEY-----
2+
b3BlbnNzaC1rZXktdjEAAAAACmFlczI1Ni1jYmMAAAAGYmNyeXB0AAAAGAAAABBg
3+
HWh+J0IG6OfYxD74SoT9AAAAEAAAAAEAAAAzAAAAC3NzaC1lZDI1NTE5AAAAIGFd
4+
yflleGqSPOhgSYZf7ZQFlG0zEL9VDGC69UbtaaByAAAAoDLm8u8wFwlqjzZRfVxj
5+
wzGTYFJFtfkHRqfFBE4xKgknHNRbCT1OQb7rgE7nZbUXIlb1NCTZLbXti9AYNZpz
6+
ycvPD4Dc6lB03b8pNHoFVSkrCwxrWB5bKtIM4OZNcDK1lZDBEWE2aZXf9puRHbu3
7+
ccrK/F5GjRi2pUa8qnfqThN1mNPZwFTx4oSKeRaUMdeHBrNwDtaxq32A6Q4KHoYO
8+
KPM=
9+
-----END OPENSSH PRIVATE KEY-----
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
-----BEGIN OPENSSH PRIVATE KEY-----
2+
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtz
3+
c2gtZWQyNTUxOQAAACANCWZw0K8UGXDQC32WBuyzwFtTGBBr1VuZ43uzpTBjIgAA
4+
AKBATgCiQE4AogAAAAtzc2gtZWQyNTUxOQAAACANCWZw0K8UGXDQC32WBuyzwFtT
5+
GBBr1VuZ43uzpTBjIgAAAEAAzBF1MPUxrs+ycpJh28zzo/F3m6WcKO+orsSbR5Lw
6+
KQ0JZnDQrxQZcNALfZYG7LPAW1MYEGvVW5nje7OlMGMiAAAAFGVkMjU1MTkta2V5
7+
LTIwMTgxMTI3AQIDBAUGBwgJ
8+
-----END OPENSSH PRIVATE KEY-----

src/Renci.SshNet.Tests/Renci.SshNet.Tests.csproj

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -719,6 +719,10 @@
719719
<EmbeddedResource Include="Data\Key.ECDSA384.Encrypted.txt" />
720720
<EmbeddedResource Include="Data\Key.ECDSA521.Encrypted.txt" />
721721
</ItemGroup>
722+
<ItemGroup>
723+
<EmbeddedResource Include="Data\Key.OPENSSH.ED25519.Encrypted.txt" />
724+
<EmbeddedResource Include="Data\Key.OPENSSH.ED25519.txt" />
725+
</ItemGroup>
722726
<Import Project="$(MSBuildBinPath)\Microsoft.CSharp.targets" />
723727
<!-- To modify your build process, add your task inside one of the targets below and uncomment it.
724728
Other similar extension points exist, see Microsoft.Common.targets.

src/Renci.SshNet/ConnectionInfo.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -378,6 +378,7 @@ public ConnectionInfo(string host, int port, string username, ProxyTypes proxyTy
378378

379379
HostKeyAlgorithms = new Dictionary<string, Func<byte[], KeyHostAlgorithm>>
380380
{
381+
{"ssh-ed25519", data => new KeyHostAlgorithm("ssh-ed25519", new ED25519Key(), data)},
381382
#if FEATURE_ECDSA
382383
{"ecdsa-sha2-nistp256", data => new KeyHostAlgorithm("ecdsa-sha2-nistp256", new EcdsaKey(), data)},
383384
{"ecdsa-sha2-nistp384", data => new KeyHostAlgorithm("ecdsa-sha2-nistp384", new EcdsaKey(), data)},

src/Renci.SshNet/PrivateKeyFile.cs

Lines changed: 154 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
using Renci.SshNet.Security.Cryptography.Ciphers.Modes;
1212
using Renci.SshNet.Security.Cryptography.Ciphers.Paddings;
1313
using System.Diagnostics.CodeAnalysis;
14+
using Renci.SshNet.Security.Cryptography;
1415

1516
namespace Renci.SshNet
1617
{
@@ -25,13 +26,16 @@ namespace Renci.SshNet
2526
/// The following private keys are supported:
2627
/// <list type="bullet">
2728
/// <item>
28-
/// <description>RSA in OpenSSH and ssh.com format</description>
29+
/// <description>RSA in OpenSSL PEM and ssh.com format</description>
2930
/// </item>
3031
/// <item>
31-
/// <description>DSA in OpenSSH and ssh.com format</description>
32+
/// <description>DSA in OpenSSL PEM and ssh.com format</description>
3233
/// </item>
3334
/// <item>
34-
/// <description>ECDSA 256/384/521 in OpenSSH format</description>
35+
/// <description>ECDSA 256/384/521 in OpenSSL PEM format</description>
36+
/// </item>
37+
/// <item>
38+
/// <description>ED25519 in OpenSSH key format</description>
3539
/// </item>
3640
/// </list>
3741
/// </para>
@@ -214,6 +218,10 @@ private void Open(Stream privateKey, string passPhrase)
214218
HostKey = new KeyHostAlgorithm(_key.ToString(), _key);
215219
break;
216220
#endif
221+
case "OPENSSH":
222+
_key = ParseOpenSshV1Key(decryptedData, passPhrase);
223+
HostKey = new KeyHostAlgorithm(_key.ToString(), _key);
224+
break;
217225
case "SSH2 ENCRYPTED":
218226
var reader = new SshDataReader(decryptedData);
219227
var magicNumber = reader.ReadUInt32();
@@ -358,6 +366,144 @@ private static byte[] DecryptKey(CipherInfo cipherInfo, byte[] cipherData, strin
358366
return cipher.Decrypt(cipherData);
359367
}
360368

369+
/// <summary>
370+
/// Parses an OpenSSH V1 key file (i.e. ED25519 key) according to the the key spec:
371+
/// https://github.com/openssh/openssh-portable/blob/master/PROTOCOL.key.
372+
/// </summary>
373+
/// <param name="keyFileData">the key file data (i.e. base64 encoded data between the header/footer)</param>
374+
/// <param name="passPhrase">passphrase or null if there isn't one</param>
375+
/// <returns></returns>
376+
private ED25519Key ParseOpenSshV1Key(byte [] keyFileData, string passPhrase)
377+
{
378+
var keyReader = new SshDataReader(keyFileData);
379+
380+
//check magic header
381+
var authMagic = Encoding.UTF8.GetBytes("openssh-key-v1\0");
382+
var keyHeaderBytes = keyReader.ReadBytes(authMagic.Length);
383+
if (!authMagic.IsEqualTo(keyHeaderBytes))
384+
{
385+
throw new SshException("This openssh key does not contain the 'openssh-key-v1' format magic header");
386+
}
387+
388+
//cipher will be "aes256-cbc" if using a passphrase, "none" otherwise
389+
var cipherName = keyReader.ReadString(Encoding.UTF8);
390+
//key derivation function (kdf): bcrypt or nothing
391+
var kdfName = keyReader.ReadString(Encoding.UTF8);
392+
//kdf options length: 24 if passphrase, 0 if no passphrase
393+
var kdfOptionsLen = (int)keyReader.ReadUInt32();
394+
byte[] salt = null;
395+
int rounds = 0;
396+
if (kdfOptionsLen > 0)
397+
{
398+
var saltLength = (int)keyReader.ReadUInt32();
399+
salt = keyReader.ReadBytes(saltLength);
400+
rounds = (int)keyReader.ReadUInt32();
401+
}
402+
403+
//number of public keys, only supporting 1 for now
404+
var numberOfPublicKeys = (int)keyReader.ReadUInt32();
405+
if (numberOfPublicKeys != 1)
406+
{
407+
throw new SshException("At this time only one public key in the openssh key is supported.");
408+
}
409+
410+
//length of first public key section
411+
keyReader.ReadUInt32();
412+
var keyType = keyReader.ReadString(Encoding.UTF8);
413+
if(keyType != "ssh-ed25519")
414+
{
415+
throw new SshException("openssh key type: " + keyType + " is not supported");
416+
}
417+
418+
//read public key
419+
var publicKeyLength = (int)keyReader.ReadUInt32(); //32
420+
var publicKey = keyReader.ReadBytes(publicKeyLength);
421+
422+
//possibly encrypted private key
423+
var privateKeyLength = (int)keyReader.ReadUInt32();
424+
var privateKeyBytes = keyReader.ReadBytes(privateKeyLength);
425+
426+
//decrypt private key if necessary
427+
if (cipherName == "aes256-cbc")
428+
{
429+
if (string.IsNullOrEmpty(passPhrase))
430+
{
431+
throw new SshPassPhraseNullOrEmptyException("Private key is encrypted but passphrase is empty.");
432+
}
433+
if (string.IsNullOrEmpty(kdfName) || kdfName != "bcrypt")
434+
{
435+
throw new SshException("kdf " + kdfName + " is not supported for openssh key file");
436+
}
437+
438+
//inspired by the SSHj library (https://github.com/hierynomus/sshj)
439+
//apply the kdf to derive a key and iv from the passphrase
440+
var passPhraseBytes = Encoding.UTF8.GetBytes(passPhrase);
441+
byte[] keyiv = new byte[48];
442+
new BCrypt().Pbkdf(passPhraseBytes, salt, rounds, keyiv);
443+
byte[] key = new byte[32];
444+
Array.Copy(keyiv, 0, key, 0, 32);
445+
byte[] iv = new byte[16];
446+
Array.Copy(keyiv, 32, iv, 0, 16);
447+
448+
//now that we have the key/iv, use a cipher to decrypt the bytes
449+
var cipher = new AesCipher(key, new CbcCipherMode(iv), new PKCS7Padding());
450+
privateKeyBytes = cipher.Decrypt(privateKeyBytes);
451+
}
452+
else if (cipherName != "none")
453+
{
454+
throw new SshException("cipher name " + cipherName + " for openssh key file is not supported");
455+
}
456+
457+
//validate private key length
458+
privateKeyLength = privateKeyBytes.Length;
459+
if (privateKeyLength % 8 != 0)
460+
{
461+
throw new SshException("The private key section must be a multiple of the block size (8)");
462+
}
463+
464+
//now parse the data we called the private key, it actually contains the public key again
465+
//so we need to parse through it to get the private key bytes, plus there's some
466+
//validation we need to do.
467+
var privateKeyReader = new SshDataReader(privateKeyBytes);
468+
469+
//check ints should match, they wouldn't match for example if the wrong passphrase was supplied
470+
int checkInt1 = (int)privateKeyReader.ReadUInt32();
471+
int checkInt2 = (int)privateKeyReader.ReadUInt32();
472+
if (checkInt1 != checkInt2)
473+
{
474+
throw new SshException("The checkints differed, the openssh key was not correctly decoded.");
475+
}
476+
477+
//key type, we already know it is ssh-ed25519
478+
privateKeyReader.ReadString(Encoding.UTF8);
479+
480+
//public key length/bytes (again)
481+
var publicKeyLength2 = (int)privateKeyReader.ReadUInt32();
482+
privateKeyReader.ReadBytes(publicKeyLength2);
483+
484+
//length of private and public key (64)
485+
privateKeyReader.ReadUInt32();
486+
var unencryptedPrivateKey = privateKeyReader.ReadBytes(32);
487+
//public key (again)
488+
privateKeyReader.ReadBytes(32);
489+
490+
//comment, we don't need this but we could log it, not sure if necessary
491+
var comment = privateKeyReader.ReadString(Encoding.UTF8);
492+
493+
//The list of privatekey/comment pairs is padded with the bytes 1, 2, 3, ...
494+
//until the total length is a multiple of the cipher block size.
495+
var padding = privateKeyReader.ReadBytes();
496+
for (int i = 0; i < padding.Length; i++)
497+
{
498+
if ((int)padding[i] != i + 1)
499+
{
500+
throw new SshException("Padding of openssh key format contained wrong byte at position: " + i);
501+
}
502+
}
503+
504+
return new ED25519Key(publicKey.Reverse(), unencryptedPrivateKey);
505+
}
506+
361507
#region IDisposable Members
362508

363509
private bool _isDisposed;
@@ -426,6 +572,11 @@ public SshDataReader(byte[] data)
426572
return base.ReadBytes(length);
427573
}
428574

575+
public new byte[] ReadBytes()
576+
{
577+
return base.ReadBytes();
578+
}
579+
429580
/// <summary>
430581
/// Reads next mpint data type from internal buffer where length specified in bits.
431582
/// </summary>

src/Renci.SshNet/Renci.SshNet.csproj

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -301,8 +301,11 @@
301301
<Compile Include="Security\Chaos.NaCl\Internal\Sha512Internal.cs" />
302302
<Compile Include="Security\Chaos.NaCl\MontgomeryCurve25519.cs" />
303303
<Compile Include="Security\Chaos.NaCl\Sha512.cs" />
304+
<Compile Include="Security\Cryptography\ED25519DigitalSignature.cs" />
304305
<Compile Include="Security\Cryptography\EcdsaDigitalSignature.cs" />
305306
<Compile Include="Security\Cryptography\EcdsaKey.cs" />
307+
<Compile Include="Security\Cryptography\ED25519Key.cs" />
308+
<Compile Include="Security\Cryptography\Bcrypt.cs" />
306309
<Compile Include="Security\Cryptography\HMACMD5.cs" />
307310
<Compile Include="Security\Cryptography\HMACSHA1.cs" />
308311
<Compile Include="Security\Cryptography\HMACSHA256.cs" />
@@ -620,4 +623,4 @@
620623
<Target Name="AfterBuild">
621624
</Target>
622625
-->
623-
</Project>
626+
</Project>

0 commit comments

Comments
 (0)