Skip to content

Commit 91ccbbb

Browse files
bhalbrightdarinkes
authored andcommitted
OpenSSH Keyformat v1 Reader with decrypt support
Needed to read ED25519 Keys generated by ssh-keygen.
1 parent 8fc109b commit 91ccbbb

File tree

8 files changed

+1157
-4
lines changed

8 files changed

+1157
-4
lines changed

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

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -608,6 +608,9 @@
608608
<Compile Include="..\Renci.SshNet\Security\Cryptography\AsymmetricCipher.cs">
609609
<Link>Security\Cryptography\AsymmetricCipher.cs</Link>
610610
</Compile>
611+
<Compile Include="..\Renci.SshNet\Security\Cryptography\Bcrypt.cs">
612+
<Link>Security\Cryptography\Bcrypt.cs</Link>
613+
</Compile>
611614
<Compile Include="..\Renci.SshNet\Security\Cryptography\BlockCipher.cs">
612615
<Link>Security\Cryptography\BlockCipher.cs</Link>
613616
</Compile>
@@ -987,4 +990,4 @@
987990
<Target Name="AfterBuild">
988991
</Target>
989992
-->
990-
</Project>
993+
</Project>

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: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -717,6 +717,10 @@
717717
<EmbeddedResource Include="Data\Key.ECDSA384.Encrypted.txt" />
718718
<EmbeddedResource Include="Data\Key.ECDSA521.Encrypted.txt" />
719719
</ItemGroup>
720+
<ItemGroup>
721+
<EmbeddedResource Include="Data\Key.OPENSSH.ED25519.Encrypted.txt" />
722+
<EmbeddedResource Include="Data\Key.OPENSSH.ED25519.txt" />
723+
</ItemGroup>
720724
<Import Project="$(MSBuildBinPath)\Microsoft.CSharp.targets" />
721725
<!-- To modify your build process, add your task inside one of the targets below and uncomment it.
722726
Other similar extension points exist, see Microsoft.Common.targets.
@@ -725,4 +729,4 @@
725729
<Target Name="AfterBuild">
726730
</Target>
727731
-->
728-
</Project>
732+
</Project>

src/Renci.SshNet/PrivateKeyFile.cs

Lines changed: 151 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@
1111
using Renci.SshNet.Security.Cryptography.Ciphers.Modes;
1212
using Renci.SshNet.Security.Cryptography.Ciphers.Paddings;
1313
using System.Diagnostics.CodeAnalysis;
14+
using System.Linq;
15+
using Renci.SshNet.Security.Cryptography;
1416

1517
namespace Renci.SshNet
1618
{
@@ -23,6 +25,7 @@ namespace Renci.SshNet
2325
/// <remarks>
2426
/// <para>
2527
/// Supports RSA and DSA private key in both <c>OpenSSH</c> and <c>ssh.com</c> format.
28+
/// Also supports ED25519 private key from OpenSSH V1 key file.
2629
/// </para>
2730
/// <para>
2831
/// The following encryption algorithms are supported:
@@ -203,6 +206,10 @@ private void Open(Stream privateKey, string passPhrase)
203206
HostKey = new KeyHostAlgorithm(_key.ToString(), _key);
204207
break;
205208
#endif
209+
case "OPENSSH":
210+
_key = ParseOpenSshV1Key(decryptedData, passPhrase);
211+
HostKey = new KeyHostAlgorithm(_key.ToString(), _key);
212+
break;
206213
case "SSH2 ENCRYPTED":
207214
var reader = new SshDataReader(decryptedData);
208215
var magicNumber = reader.ReadUInt32();
@@ -347,7 +354,145 @@ private static byte[] DecryptKey(CipherInfo cipherInfo, byte[] cipherData, strin
347354
return cipher.Decrypt(cipherData);
348355
}
349356

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

352497
private bool _isDisposed;
353498

@@ -415,6 +560,11 @@ public SshDataReader(byte[] data)
415560
return base.ReadBytes(length);
416561
}
417562

563+
public new byte[] ReadBytes()
564+
{
565+
return base.ReadBytes();
566+
}
567+
418568
/// <summary>
419569
/// Reads next mpint data type from internal buffer where length specified in bits.
420570
/// </summary>

src/Renci.SshNet/Renci.SshNet.csproj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -440,6 +440,7 @@
440440
<Compile Include="Security\Cryptography\EcdsaDigitalSignature.cs" />
441441
<Compile Include="Security\Cryptography\EcdsaKey.cs" />
442442
<Compile Include="Security\Cryptography\ED25519Key.cs" />
443+
<Compile Include="Security\Cryptography\Bcrypt.cs" />
443444
<Compile Include="Security\Cryptography\HMACMD5.cs" />
444445
<Compile Include="Security\Cryptography\HMACSHA1.cs" />
445446
<Compile Include="Security\Cryptography\HMACSHA256.cs" />

0 commit comments

Comments
 (0)