diff --git a/Directory.Packages.props b/Directory.Packages.props index cb7d637..86374ae 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -1,6 +1,7 @@ + diff --git a/README.md b/README.md index 2b38453..816ead8 100644 --- a/README.md +++ b/README.md @@ -429,7 +429,7 @@ This section lists the currently supported algorithms. If you would like support Supported private key formats: - RSA in `RSA PRIVATE KEY` -- RSA, ECDSA in `OPENSSH PRIVATE KEY` (`openssh-key-v1`) +- RSA, ECDSA, ED25519 in `OPENSSH PRIVATE KEY` (`openssh-key-v1`) Supported private key encryption cyphers: - OpenSSH Keys `OPENSSH PRIVATE KEY` (`openssh-key-v1`) diff --git a/src/Tmds.Ssh/AlgorithmNames.cs b/src/Tmds.Ssh/AlgorithmNames.cs index 9256b01..e2b07d5 100644 --- a/src/Tmds.Ssh/AlgorithmNames.cs +++ b/src/Tmds.Ssh/AlgorithmNames.cs @@ -33,6 +33,8 @@ static class AlgorithmNames // TODO: rename to KnownNames public static Name EcdsaSha2Nistp384 => new Name(EcdsaSha2Nistp384Bytes); private static readonly byte[] EcdsaSha2Nistp521Bytes = "ecdsa-sha2-nistp521"u8.ToArray(); public static Name EcdsaSha2Nistp521 => new Name(EcdsaSha2Nistp521Bytes); + private static readonly byte[] SshEd25519Bytes = "ssh-ed25519"u8.ToArray(); + public static Name SshEd25519 => new Name(SshEd25519Bytes); // Encryption algorithms. private static readonly byte[] Aes128CbcBytes = "aes128-cbc"u8.ToArray(); diff --git a/src/Tmds.Ssh/Ed25519PrivateKey.cs b/src/Tmds.Ssh/Ed25519PrivateKey.cs new file mode 100644 index 0000000..26eaf1e --- /dev/null +++ b/src/Tmds.Ssh/Ed25519PrivateKey.cs @@ -0,0 +1,63 @@ +// This file is part of Tmds.Ssh which is released under MIT. +// See file LICENSE for full license details. + +using System.Buffers; +using Org.BouncyCastle.Math.EC.Rfc8032; + +namespace Tmds.Ssh; + +sealed class Ed25519PrivateKey : PrivateKey +{ + // Contains the private and public key as one block of bytes from the + // serialized OpenSSH key data. + private readonly byte[] _privateKey; + private readonly byte[] _publicKey; + + public Ed25519PrivateKey(byte[] privateKey, byte[] publicKey) : + base([AlgorithmNames.SshEd25519]) + { + _privateKey = privateKey; + _publicKey = publicKey; + } + + public override void Dispose() + { } + + public override void AppendPublicKey(ref SequenceWriter writer) + { + using var innerData = writer.SequencePool.RentSequence(); + var innerWriter = new SequenceWriter(innerData); + innerWriter.WriteString(Algorithms[0]); + innerWriter.WriteString(_publicKey); + + writer.WriteString(innerData.AsReadOnlySequence()); + } + + public override void AppendSignature(Name algorithm, ref SequenceWriter writer, ReadOnlySequence data) + { + if (algorithm != Algorithms[0]) + { + ThrowHelper.ThrowProtocolUnexpectedValue(); + return; + } + + byte[] signature = new byte[Ed25519.SignatureSize]; + Ed25519.Sign( + _privateKey, + 0, + _publicKey, + 0, + data.ToArray(), + 0, + (int)data.Length, + signature, + 0); + + using var innerData = writer.SequencePool.RentSequence(); + var innerWriter = new SequenceWriter(innerData); + innerWriter.WriteString(algorithm); + innerWriter.WriteString(signature); + + writer.WriteString(innerData.AsReadOnlySequence()); + } +} diff --git a/src/Tmds.Ssh/PrivateKeyParser.OpenSsh.cs b/src/Tmds.Ssh/PrivateKeyParser.OpenSsh.cs index 900c603..c1f09bd 100644 --- a/src/Tmds.Ssh/PrivateKeyParser.OpenSsh.cs +++ b/src/Tmds.Ssh/PrivateKeyParser.OpenSsh.cs @@ -108,10 +108,14 @@ byte padlen % 255 { return TryParseOpenSshRsaKey(reader, out privateKey, out error); } - if (keyType.ToString().StartsWith("ecdsa-sha2-")) + else if (keyType.ToString().StartsWith("ecdsa-sha2-")) { return TryParseOpenSshEcdsaKey(keyType, reader, out privateKey, out error); } + else if (keyType == AlgorithmNames.SshEd25519) + { + return TryParseOpenSshEd25519Key(reader, out privateKey, out error); + } else { error = new NotSupportedException($"The key type is unsupported: '{keyType}'."); @@ -280,4 +284,36 @@ private static bool TryParseOpenSshEcdsaKey(Name keyIdentifier, SequenceReader r return false; } } + + private static bool TryParseOpenSshEd25519Key(SequenceReader reader, [NotNullWhen(true)] out PrivateKey? privateKey, [NotNullWhen(false)] out Exception? error) + { + privateKey = null; + + // https://datatracker.ietf.org/doc/html/draft-miller-ssh-agent-14#section-3.2.3 + /* + string ENC(A) + string k || ENC(A) + + The first value is the EDDSA public key ENC(A). The second value is a + concatenation of the private key k and the public ENC(A) key. Why it is + repeated, I have no idea. + */ + + try + { + ReadOnlySequence publicKey = reader.ReadStringAsBytes(); + ReadOnlySequence keyData = reader.ReadStringAsBytes(); + + privateKey = new Ed25519PrivateKey( + keyData.Slice(0, keyData.Length - publicKey.Length).ToArray(), + publicKey.ToArray()); + error = null; + return true; + } + catch (Exception ex) + { + error = new FormatException($"The data can not be parsed into an ED25519 key.", ex); + return false; + } + } } diff --git a/src/Tmds.Ssh/SshClientSettings.Defaults.cs b/src/Tmds.Ssh/SshClientSettings.Defaults.cs index 2044e51..5dc4dd2 100644 --- a/src/Tmds.Ssh/SshClientSettings.Defaults.cs +++ b/src/Tmds.Ssh/SshClientSettings.Defaults.cs @@ -39,7 +39,7 @@ partial class SshClientSettings internal readonly static List SupportedKeyExchangeAlgorithms = [ AlgorithmNames.EcdhSha2Nistp256, AlgorithmNames.EcdhSha2Nistp384, AlgorithmNames.EcdhSha2Nistp521 ]; internal readonly static List SupportedServerHostKeyAlgorithms = [ AlgorithmNames.EcdsaSha2Nistp521, AlgorithmNames.EcdsaSha2Nistp384, AlgorithmNames.EcdsaSha2Nistp256, AlgorithmNames.RsaSshSha2_512, AlgorithmNames.RsaSshSha2_256 ]; internal readonly static List SupportedEncryptionAlgorithms = [ AlgorithmNames.Aes256Gcm, AlgorithmNames.Aes128Gcm ]; - internal readonly static List SupportedPublicKeyAcceptedAlgorithms = [ AlgorithmNames.EcdsaSha2Nistp521, AlgorithmNames.EcdsaSha2Nistp384, AlgorithmNames.EcdsaSha2Nistp256, AlgorithmNames.RsaSshSha2_512, AlgorithmNames.RsaSshSha2_256 ]; + internal readonly static List SupportedPublicKeyAcceptedAlgorithms = [ AlgorithmNames.SshEd25519, AlgorithmNames.EcdsaSha2Nistp521, AlgorithmNames.EcdsaSha2Nistp384, AlgorithmNames.EcdsaSha2Nistp256, AlgorithmNames.RsaSshSha2_512, AlgorithmNames.RsaSshSha2_256 ]; internal readonly static List SupportedMacAlgorithms = EmptyList; internal readonly static List SupportedCompressionAlgorithms = [ AlgorithmNames.None ]; internal readonly static List DisableCompressionAlgorithms = [ AlgorithmNames.None ]; diff --git a/src/Tmds.Ssh/Tmds.Ssh.csproj b/src/Tmds.Ssh/Tmds.Ssh.csproj index cd97729..ba58bff 100644 --- a/src/Tmds.Ssh/Tmds.Ssh.csproj +++ b/src/Tmds.Ssh/Tmds.Ssh.csproj @@ -19,6 +19,7 @@ + diff --git a/test/Tmds.Ssh.Tests/PrivateKeyCredentialTests.cs b/test/Tmds.Ssh.Tests/PrivateKeyCredentialTests.cs index da418e5..c5964a9 100644 --- a/test/Tmds.Ssh.Tests/PrivateKeyCredentialTests.cs +++ b/test/Tmds.Ssh.Tests/PrivateKeyCredentialTests.cs @@ -108,6 +108,20 @@ await RunWithKeyConversion(_sshServer.TestUserIdentityFileEcdsa521, async (strin }, async (c) => await c.ConnectAsync()); } + [Theory] + [InlineData(null)] + [InlineData("aes256-ctr")] + public async Task OpenSshEd25519Key(string? cipher) + { + await RunWithKeyConversion(_sshServer.TestUserIdentityFileEd25519, async (string localKey) => + { + string? keyPass = string.IsNullOrWhiteSpace(cipher) ? null : TestPassword; + await EncryptSshKey(localKey, "RFC4716", keyPass, cipher); + + return new PrivateKeyCredential(localKey, keyPass); + }, async (c) => await c.ConnectAsync()); + } + [Fact] public async Task OpenSshKeyPromptNotCalledForPlaintextKey() { diff --git a/test/Tmds.Ssh.Tests/SshClientSettingsTests.cs b/test/Tmds.Ssh.Tests/SshClientSettingsTests.cs index a270cf4..0c9a9c6 100644 --- a/test/Tmds.Ssh.Tests/SshClientSettingsTests.cs +++ b/test/Tmds.Ssh.Tests/SshClientSettingsTests.cs @@ -25,7 +25,7 @@ public void Defaults() Assert.Null(settings.HostAuthentication); Assert.Equal(new[] { new Name("ecdh-sha2-nistp256"), new Name("ecdh-sha2-nistp384"), new Name("ecdh-sha2-nistp521") }, settings.KeyExchangeAlgorithms); Assert.Equal(new[] { new Name("ecdsa-sha2-nistp521"), new Name("ecdsa-sha2-nistp384"), new Name("ecdsa-sha2-nistp256"), new Name("rsa-sha2-512"), new Name("rsa-sha2-256") }, settings.ServerHostKeyAlgorithms); - Assert.Equal(new[] { new Name("ecdsa-sha2-nistp521"), new Name("ecdsa-sha2-nistp384"), new Name("ecdsa-sha2-nistp256"), new Name("rsa-sha2-512"), new Name("rsa-sha2-256") }, settings.PublicKeyAcceptedAlgorithms); + Assert.Equal(new[] { new Name("ssh-ed25519"), new Name("ecdsa-sha2-nistp521"), new Name("ecdsa-sha2-nistp384"), new Name("ecdsa-sha2-nistp256"), new Name("rsa-sha2-512"), new Name("rsa-sha2-256") }, settings.PublicKeyAcceptedAlgorithms); Assert.Equal(new[] { new Name("aes256-gcm@openssh.com"), new Name("aes128-gcm@openssh.com") }, settings.EncryptionAlgorithmsClientToServer); Assert.Equal(new[] { new Name("aes256-gcm@openssh.com"), new Name("aes128-gcm@openssh.com") }, settings.EncryptionAlgorithmsServerToClient); Assert.Equal(Array.Empty(), settings.MacAlgorithmsClientToServer); diff --git a/test/Tmds.Ssh.Tests/SshServer.cs b/test/Tmds.Ssh.Tests/SshServer.cs index 13008a4..057dad2 100644 --- a/test/Tmds.Ssh.Tests/SshServer.cs +++ b/test/Tmds.Ssh.Tests/SshServer.cs @@ -26,6 +26,7 @@ public class SshServer : IDisposable public string TestUserIdentityFileEcdsa256 => $"{ContainerBuildContext}/user_key_ecdsa_256"; public string TestUserIdentityFileEcdsa384 => $"{ContainerBuildContext}/user_key_ecdsa_384"; public string TestUserIdentityFileEcdsa521 => $"{ContainerBuildContext}/user_key_ecdsa_521"; + public string TestUserIdentityFileEd25519 => $"{ContainerBuildContext}/user_key_ed25519"; public string TestSubsystem = "test_subsystem"; public string ServerHost => _host; public int ServerPort => _port; diff --git a/test/Tmds.Ssh.Tests/sshd_container/Dockerfile b/test/Tmds.Ssh.Tests/sshd_container/Dockerfile index 88759d9..ff2bf70 100644 --- a/test/Tmds.Ssh.Tests/sshd_container/Dockerfile +++ b/test/Tmds.Ssh.Tests/sshd_container/Dockerfile @@ -27,6 +27,7 @@ COPY user_key_rsa.pub /home/testuser/.ssh/user_key_rsa.pub COPY user_key_ecdsa_256.pub /home/testuser/.ssh/user_key_ecdsa_256.pub COPY user_key_ecdsa_384.pub /home/testuser/.ssh/user_key_ecdsa_384.pub COPY user_key_ecdsa_521.pub /home/testuser/.ssh/user_key_ecdsa_521.pub +COPY user_key_ed25519.pub /home/testuser/.ssh/user_key_ed25519.pub RUN cat /home/testuser/.ssh/user_key_*.pub > /home/testuser/.ssh/authorized_keys RUN chown -R testuser:testuser /home/testuser/.ssh RUN chmod 600 /home/testuser/.ssh/authorized_keys diff --git a/test/Tmds.Ssh.Tests/sshd_container/user_key_ed25519 b/test/Tmds.Ssh.Tests/sshd_container/user_key_ed25519 new file mode 100644 index 0000000..657a5a5 --- /dev/null +++ b/test/Tmds.Ssh.Tests/sshd_container/user_key_ed25519 @@ -0,0 +1,7 @@ +-----BEGIN OPENSSH PRIVATE KEY----- +b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW +QyNTUxOQAAACDBGs/6MWZXV20KtacQson8/EyafCK7KDCh0ZH8LBgvYQAAAKA1mv4HNZr+ +BwAAAAtzc2gtZWQyNTUxOQAAACDBGs/6MWZXV20KtacQson8/EyafCK7KDCh0ZH8LBgvYQ +AAAEAzz1majY8Z1JKFhHlfyGiApNGarWquj0JNEG7TQynbCcEaz/oxZldXbQq1pxCyifz8 +TJp8IrsoMKHRkfwsGC9hAAAAGnRtZHNAbG9jYWxob3N0LmxvY2FsZG9tYWluAQID +-----END OPENSSH PRIVATE KEY----- diff --git a/test/Tmds.Ssh.Tests/sshd_container/user_key_ed25519.pub b/test/Tmds.Ssh.Tests/sshd_container/user_key_ed25519.pub new file mode 100644 index 0000000..3521b82 --- /dev/null +++ b/test/Tmds.Ssh.Tests/sshd_container/user_key_ed25519.pub @@ -0,0 +1 @@ +ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIMEaz/oxZldXbQq1pxCyifz8TJp8IrsoMKHRkfwsGC9h tmds@localhost.localdomain