diff --git a/OpenSSH_GUI.Core/Interfaces/IHostKeyTrustPrompt.cs b/OpenSSH_GUI.Core/Interfaces/IHostKeyTrustPrompt.cs new file mode 100644 index 0000000..884cdce --- /dev/null +++ b/OpenSSH_GUI.Core/Interfaces/IHostKeyTrustPrompt.cs @@ -0,0 +1,12 @@ +using OpenSSH_GUI.Core.Lib.HostKeys; + +namespace OpenSSH_GUI.Core.Interfaces; + +/// +/// Asks the user whether an unknown server host key should be trusted (trust on first use). +/// +public interface IHostKeyTrustPrompt +{ + /// true if the user confirmed the fingerprint and the key should be trusted. + Task ConfirmUnknownHostKeyAsync(HostKeyInfo hostKey); +} diff --git a/OpenSSH_GUI.Core/Interfaces/IKnownHostKeyStore.cs b/OpenSSH_GUI.Core/Interfaces/IKnownHostKeyStore.cs new file mode 100644 index 0000000..9781e26 --- /dev/null +++ b/OpenSSH_GUI.Core/Interfaces/IKnownHostKeyStore.cs @@ -0,0 +1,19 @@ +using OpenSSH_GUI.Core.Lib.HostKeys; + +namespace OpenSSH_GUI.Core.Interfaces; + +/// +/// Verifies server host keys against the user's known_hosts file and records newly trusted keys. +/// +public interface IKnownHostKeyStore +{ + /// + /// Checks the presented host key against the known_hosts entries of the host. + /// + HostKeyVerificationStatus Verify(HostKeyInfo hostKey); + + /// + /// Appends the host key to known_hosts. + /// + void Add(HostKeyInfo hostKey); +} diff --git a/OpenSSH_GUI.Core/Lib/HostKeys/HostKeyInfo.cs b/OpenSSH_GUI.Core/Lib/HostKeys/HostKeyInfo.cs new file mode 100644 index 0000000..e690c65 --- /dev/null +++ b/OpenSSH_GUI.Core/Lib/HostKeys/HostKeyInfo.cs @@ -0,0 +1,49 @@ +using System.Buffers.Binary; +using System.Security.Cryptography; +using System.Text; + +namespace OpenSSH_GUI.Core.Lib.HostKeys; + +/// +/// A server host key as presented during the SSH key exchange. +/// +/// The host name the connection was made to. +/// The port the connection was made to. +/// The public key in SSH wire format. +public sealed record HostKeyInfo(string Host, int Port, byte[] KeyBlob) +{ + /// + /// The key type encoded in the key blob, e.g. ssh-ed25519. + /// + public string KeyType { get; } = ReadKeyType(KeyBlob) ?? string.Empty; + + /// + /// The fingerprint in OpenSSH notation, e.g. SHA256:.... + /// + public string FingerprintSha256 => + $"SHA256:{Convert.ToBase64String(SHA256.HashData(KeyBlob)).TrimEnd('=')}"; + + /// + /// The host name as written to and matched against known_hosts + /// ([host]:port for non-default ports). + /// + public string KnownHostsName => GetKnownHostsName(Host, Port); + + public static string GetKnownHostsName(string host, int port) + { + // OpenSSH canonicalizes host names to lower case before looking them up in known_hosts. + var name = host.ToLowerInvariant(); + return port == 22 ? name : $"[{name}]:{port}"; + } + + /// + /// Reads the leading key type string of an SSH wire format public key. + /// + public static string? ReadKeyType(ReadOnlySpan keyBlob) + { + if (keyBlob.Length < sizeof(uint)) return null; + var length = BinaryPrimitives.ReadUInt32BigEndian(keyBlob); + if (length == 0 || length > keyBlob.Length - sizeof(uint)) return null; + return Encoding.ASCII.GetString(keyBlob.Slice(sizeof(uint), (int)length)); + } +} diff --git a/OpenSSH_GUI.Core/Lib/HostKeys/HostKeyVerificationException.cs b/OpenSSH_GUI.Core/Lib/HostKeys/HostKeyVerificationException.cs new file mode 100644 index 0000000..3409594 --- /dev/null +++ b/OpenSSH_GUI.Core/Lib/HostKeys/HostKeyVerificationException.cs @@ -0,0 +1,26 @@ +namespace OpenSSH_GUI.Core.Lib.HostKeys; + +/// +/// Thrown when a connection is aborted because the server host key could not be verified. +/// +public sealed class HostKeyVerificationException(HostKeyInfo hostKey, HostKeyVerificationStatus status) + : Exception(CreateMessage(hostKey, status)) +{ + public HostKeyInfo HostKey { get; } = hostKey; + + public HostKeyVerificationStatus Status { get; } = status; + + private static string CreateMessage(HostKeyInfo hostKey, HostKeyVerificationStatus status) => status switch + { + HostKeyVerificationStatus.Mismatch => + $"The {hostKey.KeyType} host key of {hostKey.KnownHostsName} does not match the key in known_hosts " + + $"({hostKey.FingerprintSha256}). Someone could be eavesdropping on the connection (man-in-the-middle " + + "attack), or the host key has been changed. Connection aborted.", + HostKeyVerificationStatus.Revoked => + $"The {hostKey.KeyType} host key of {hostKey.KnownHostsName} ({hostKey.FingerprintSha256}) " + + "is marked as revoked in known_hosts. Connection aborted.", + _ => + $"The authenticity of host {hostKey.KnownHostsName} ({hostKey.KeyType} {hostKey.FingerprintSha256}) " + + "was not confirmed. Connection aborted." + }; +} diff --git a/OpenSSH_GUI.Core/Lib/HostKeys/HostKeyVerificationStatus.cs b/OpenSSH_GUI.Core/Lib/HostKeys/HostKeyVerificationStatus.cs new file mode 100644 index 0000000..96959c1 --- /dev/null +++ b/OpenSSH_GUI.Core/Lib/HostKeys/HostKeyVerificationStatus.cs @@ -0,0 +1,19 @@ +namespace OpenSSH_GUI.Core.Lib.HostKeys; + +/// +/// Result of checking a server host key against the local known_hosts file. +/// +public enum HostKeyVerificationStatus +{ + /// No entry exists for the host and key type. + Unknown, + + /// An entry for the host matches the presented key. + Trusted, + + /// The host is known with a different key of the same type - possible man-in-the-middle attack. + Mismatch, + + /// The presented key is marked as @revoked. + Revoked +} diff --git a/OpenSSH_GUI.Core/Lib/Misc/ServerConnection.cs b/OpenSSH_GUI.Core/Lib/Misc/ServerConnection.cs index 0749309..08528b0 100644 --- a/OpenSSH_GUI.Core/Lib/Misc/ServerConnection.cs +++ b/OpenSSH_GUI.Core/Lib/Misc/ServerConnection.cs @@ -5,10 +5,12 @@ using OpenSSH_GUI.Core.Enums; using OpenSSH_GUI.Core.Extensions; using OpenSSH_GUI.Core.Lib.AuthorizedKeys; +using OpenSSH_GUI.Core.Lib.HostKeys; using OpenSSH_GUI.Core.Lib.KnownHosts; using ReactiveUI; using ReactiveUI.SourceGenerators; using Renci.SshNet; +using Renci.SshNet.Common; namespace OpenSSH_GUI.Core.Lib.Misc; @@ -38,7 +40,8 @@ public sealed partial class ServerConnection : ReactiveObject, IDisposable [Reactive(SetModifier = AccessModifier.Private)] private PlatformID _serverOs = PlatformID.Other; - private ServerConnection(ConnectionCredentials? credentials = null) + private ServerConnection(ConnectionCredentials? credentials = null, + Func? hostKeyValidator = null) { ConnectionCredentials = credentials ?? ConnectionCredentials.Empty; var connectionInfo = ConnectionCredentials.GetConnectionInfo(); @@ -48,6 +51,14 @@ private ServerConnection(ConnectionCredentials? credentials = null) }; FileTransferConnection = new SftpClient(connectionInfo); + // Without a validator every host key would be accepted - reject unless explicitly verified. + hostKeyValidator ??= _ => false; + EventHandler onHostKeyReceived = (_, args) => + args.CanTrust = hostKeyValidator( + new HostKeyInfo(ConnectionCredentials.Hostname, ConnectionCredentials.Port, args.HostKey)); + ClientConnection.HostKeyReceived += onHostKeyReceived; + FileTransferConnection.HostKeyReceived += onHostKeyReceived; + _connectionStringHelper = this.WhenAnyValue(obj => obj.IsConnected) .Select(c => c ? $"{ConnectionCredentials.Username}@{ConnectionCredentials.Hostname}" : string.Empty) .ToProperty(this, obj => obj.ConnectionString) @@ -91,7 +102,16 @@ public void Dispose() ClientConnection.Dispose(); } - public static ServerConnection WithCredentials(ConnectionCredentials credentials) => new(credentials); + /// + /// Creates a connection for the given credentials. + /// + /// The credentials used to authenticate. + /// + /// Decides whether the host key presented by the server is trusted. The connection is aborted + /// during the key exchange when it returns false. + /// + public static ServerConnection WithCredentials(ConnectionCredentials credentials, + Func hostKeyValidator) => new(credentials, hostKeyValidator); public async ValueTask ConnectToServerAsync(CancellationToken token = default) { diff --git a/OpenSSH_GUI.Core/Services/KnownHostKeyStore.cs b/OpenSSH_GUI.Core/Services/KnownHostKeyStore.cs new file mode 100644 index 0000000..7311bb1 --- /dev/null +++ b/OpenSSH_GUI.Core/Services/KnownHostKeyStore.cs @@ -0,0 +1,168 @@ +using System.Security.Cryptography; +using System.Text; +using Microsoft.Extensions.Logging; +using OpenSSH_GUI.Core.Enums; +using OpenSSH_GUI.Core.Extensions; +using OpenSSH_GUI.Core.Interfaces; +using OpenSSH_GUI.Core.Lib.HostKeys; +using OpenSSH_GUI.SshConfig.Parsers; + +namespace OpenSSH_GUI.Core.Services; + +/// +/// backed by the user's known_hosts file. +/// Supports plain and hashed (|1|salt|hash) host names, [host]:port entries, +/// wildcard and negated patterns as well as the @revoked marker. +/// @cert-authority entries are ignored. +/// +public sealed class KnownHostKeyStore : IKnownHostKeyStore +{ + private const string HashedHostPrefix = "|1|"; + private const string RevokedMarker = "@revoked"; + + private readonly Lock _fileLock = new(); + private readonly string _knownHostsPath; + private readonly ILogger _logger; + + public KnownHostKeyStore(ILogger logger) + : this(logger, SshConfigFiles.Known_Hosts.GetPathOfFile()) { } + + public KnownHostKeyStore(ILogger logger, string knownHostsPath) + { + _logger = logger; + _knownHostsPath = knownHostsPath; + } + + /// + public HostKeyVerificationStatus Verify(HostKeyInfo hostKey) + { + string[] lines; + lock (_fileLock) + { + if (!File.Exists(_knownHostsPath)) return HostKeyVerificationStatus.Unknown; + lines = File.ReadAllLines(_knownHostsPath); + } + + var hostName = hostKey.KnownHostsName; + var trusted = false; + var mismatch = false; + + foreach (var rawLine in lines) + { + var line = rawLine.Trim(); + if (line.Length == 0 || line[0] == '#') continue; + + var fields = line.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries); + string? marker = null; + if (fields[0][0] == '@') + { + marker = fields[0]; + fields = fields[1..]; + } + + if (fields.Length < 3) continue; + if (!HostPatternMatches(hostName, fields[0])) continue; + + byte[] storedKey; + try + { + storedKey = Convert.FromBase64String(fields[2]); + } + catch (FormatException) + { + continue; + } + + var sameKey = CryptographicOperations.FixedTimeEquals(storedKey, hostKey.KeyBlob); + switch (marker) + { + case RevokedMarker when sameKey: + return HostKeyVerificationStatus.Revoked; + case null when sameKey: + trusted = true; + break; + case null when string.Equals(HostKeyInfo.ReadKeyType(storedKey), hostKey.KeyType, StringComparison.Ordinal): + mismatch = true; + break; + } + } + + if (trusted) return HostKeyVerificationStatus.Trusted; + if (mismatch) + { + _logger.LogWarning( + "Host key mismatch for {host}: {keyType} {fingerprint}", hostName, hostKey.KeyType, + hostKey.FingerprintSha256); + return HostKeyVerificationStatus.Mismatch; + } + + return HostKeyVerificationStatus.Unknown; + } + + /// + public void Add(HostKeyInfo hostKey) + { + var entry = $"{hostKey.KnownHostsName} {hostKey.KeyType} {Convert.ToBase64String(hostKey.KeyBlob)}\n"; + lock (_fileLock) + { + var directory = Path.GetDirectoryName(_knownHostsPath); + if (!string.IsNullOrEmpty(directory)) + { + if (OperatingSystem.IsWindows()) + Directory.CreateDirectory(directory); + else + Directory.CreateDirectory( + directory, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute); + } + + var options = new FileStreamOptions + { + Access = FileAccess.ReadWrite, + Mode = FileMode.OpenOrCreate, + Share = FileShare.Read + }; + if (!OperatingSystem.IsWindows()) + options.UnixCreateMode = UnixFileMode.UserRead | UnixFileMode.UserWrite; + + using var stream = new FileStream(_knownHostsPath, options); + if (stream.Length > 0) + { + stream.Seek(-1, SeekOrigin.End); + if (stream.ReadByte() != '\n') entry = "\n" + entry; + } + + stream.Seek(0, SeekOrigin.End); + stream.Write(Encoding.ASCII.GetBytes(entry)); + } + + _logger.LogInformation( + "Added {keyType} host key {fingerprint} for {host} to {path}", hostKey.KeyType, + hostKey.FingerprintSha256, hostKey.KnownHostsName, _knownHostsPath); + } + + private static bool HostPatternMatches(string hostName, string patterns) + { + if (patterns.StartsWith(HashedHostPrefix, StringComparison.Ordinal)) + return HashedHostMatches(hostName, patterns); + + return SshWildcardMatcher.Matches(hostName, patterns.Split(',')); + } + + private static bool HashedHostMatches(string hostName, string hashedEntry) + { + // |1|| + var parts = hashedEntry.Split('|'); + if (parts.Length != 4) return false; + try + { + var salt = Convert.FromBase64String(parts[2]); + var expected = Convert.FromBase64String(parts[3]); + var actual = HMACSHA1.HashData(salt, Encoding.UTF8.GetBytes(hostName)); + return CryptographicOperations.FixedTimeEquals(actual, expected); + } + catch (FormatException) + { + return false; + } + } +} diff --git a/OpenSSH_GUI.Core/Services/ServerConnectionService.cs b/OpenSSH_GUI.Core/Services/ServerConnectionService.cs index ad73a81..daef204 100644 --- a/OpenSSH_GUI.Core/Services/ServerConnectionService.cs +++ b/OpenSSH_GUI.Core/Services/ServerConnectionService.cs @@ -2,6 +2,8 @@ using System.Reactive.Disposables.Fluent; using System.Reactive.Linq; using Microsoft.Extensions.Logging; +using OpenSSH_GUI.Core.Interfaces; +using OpenSSH_GUI.Core.Lib.HostKeys; using OpenSSH_GUI.Core.Lib.Misc; using ReactiveUI; using ReactiveUI.SourceGenerators; @@ -12,6 +14,10 @@ public sealed partial class ServerConnectionService : ReactiveObject, IDisposabl { private readonly CompositeDisposable _disposables = new(); + private readonly IHostKeyTrustPrompt _hostKeyTrustPrompt; + + private readonly IKnownHostKeyStore _knownHostKeyStore; + private readonly ILogger _logger; /// @@ -36,9 +42,12 @@ public sealed partial class ServerConnectionService : ReactiveObject, IDisposabl [Reactive(SetModifier = AccessModifier.Private)] private ServerConnection _serverConnection = ServerConnection.Empty; - public ServerConnectionService(ILogger logger) + public ServerConnectionService(ILogger logger, IKnownHostKeyStore knownHostKeyStore, + IHostKeyTrustPrompt hostKeyTrustPrompt) { _logger = logger; + _knownHostKeyStore = knownHostKeyStore; + _hostKeyTrustPrompt = hostKeyTrustPrompt; _isConnectedHelper = this.WhenAnyValue(vm => vm.ServerConnection) .Select(e => e.WhenAnyValue(sc => sc.IsConnected)) @@ -73,8 +82,21 @@ public async ValueTask EstablishConnection(ConnectionCredentials connectio { try { - ServerConnection = ServerConnection.WithCredentials(connectionCredentials); - return await ServerConnection.ConnectToServerAsync(token); + var (connected, rejectedKey) = await TryConnectAsync(connectionCredentials, token); + if (rejectedKey is null) return connected; + + if (rejectedKey.Status is not HostKeyVerificationStatus.Unknown) + throw new HostKeyVerificationException(rejectedKey.HostKey, rejectedKey.Status); + + // Trust on first use: the user has to confirm the fingerprint before the key is stored. + if (!await _hostKeyTrustPrompt.ConfirmUnknownHostKeyAsync(rejectedKey.HostKey)) + throw new HostKeyVerificationException(rejectedKey.HostKey, rejectedKey.Status); + + _knownHostKeyStore.Add(rejectedKey.HostKey); + (connected, rejectedKey) = await TryConnectAsync(connectionCredentials, token); + return rejectedKey is null + ? connected + : throw new HostKeyVerificationException(rejectedKey.HostKey, rejectedKey.Status); } catch (Exception e) { @@ -83,6 +105,50 @@ public async ValueTask EstablishConnection(ConnectionCredentials connectio } } + /// + /// Connects with host key verification against known_hosts. + /// + /// + /// The connection result, or the rejected host key if the connection was aborted + /// because the host key could not be verified. + /// + private async ValueTask<(bool Connected, RejectedHostKey? RejectedKey)> TryConnectAsync( + ConnectionCredentials connectionCredentials, CancellationToken token) + { + RejectedHostKey? rejectedKey = null; + DisposeCurrentConnection(); + ServerConnection = ServerConnection.WithCredentials( + connectionCredentials, hostKey => + { + var status = _knownHostKeyStore.Verify(hostKey); + if (status is HostKeyVerificationStatus.Trusted) return true; + rejectedKey ??= new RejectedHostKey(hostKey, status); + return false; + }); + + try + { + return (await ServerConnection.ConnectToServerAsync(token), null); + } + catch (Exception e) when (rejectedKey is not null) + { + _logger.LogWarning( + e, "Host key {fingerprint} of {host} rejected: {status}", rejectedKey.HostKey.FingerprintSha256, + rejectedKey.HostKey.KnownHostsName, rejectedKey.Status); + DisposeCurrentConnection(); + ServerConnection = ServerConnection.Empty; + return (false, rejectedKey); + } + } + + private void DisposeCurrentConnection() + { + if (!ReferenceEquals(ServerConnection, ServerConnection.Empty)) + ServerConnection.Dispose(); + } + + private sealed record RejectedHostKey(HostKeyInfo HostKey, HostKeyVerificationStatus Status); + /// /// Closes the current connection to the server if a connection exists. /// diff --git a/OpenSSH_GUI.Tests/Core/Services/KnownHostKeyStoreTests.cs b/OpenSSH_GUI.Tests/Core/Services/KnownHostKeyStoreTests.cs new file mode 100644 index 0000000..6f104b3 --- /dev/null +++ b/OpenSSH_GUI.Tests/Core/Services/KnownHostKeyStoreTests.cs @@ -0,0 +1,154 @@ +using System.Security.Cryptography; +using System.Text; +using Microsoft.Extensions.Logging.Abstractions; +using OpenSSH_GUI.Core.Lib.HostKeys; +using OpenSSH_GUI.Core.Services; +using Shouldly; +using Xunit; + +namespace OpenSSH_GUI.Tests.Core.Services; + +public sealed class KnownHostKeyStoreTests : IDisposable +{ + private readonly string _directory = Directory.CreateTempSubdirectory("knownhosts").FullName; + private readonly string _path; + private readonly KnownHostKeyStore _store; + + public KnownHostKeyStoreTests() + { + _path = Path.Combine(_directory, "known_hosts"); + _store = new KnownHostKeyStore(NullLogger.Instance, _path); + } + + public void Dispose() { Directory.Delete(_directory, true); } + + private static byte[] CreateKeyBlob(string keyType, byte fill) + { + var type = Encoding.ASCII.GetBytes(keyType); + var blob = new byte[4 + type.Length + 32]; + blob[3] = (byte)type.Length; + type.CopyTo(blob, 4); + blob.AsSpan(4 + type.Length).Fill(fill); + return blob; + } + + private static readonly byte[] Ed25519Key = CreateKeyBlob("ssh-ed25519", 1); + private static readonly byte[] OtherEd25519Key = CreateKeyBlob("ssh-ed25519", 2); + private static readonly byte[] EcdsaKey = CreateKeyBlob("ecdsa-sha2-nistp256", 3); + + private void WriteKnownHosts(params string[] lines) => File.WriteAllLines(_path, lines); + + private static string Entry(string hosts, byte[] key, string? marker = null) => + $"{(marker is null ? string.Empty : marker + " ")}{hosts} {HostKeyInfo.ReadKeyType(key)} {Convert.ToBase64String(key)}"; + + [Fact] + public void Verify_MissingFile_ReturnsUnknown() + { + _store.Verify(new HostKeyInfo("example.com", 22, Ed25519Key)).ShouldBe(HostKeyVerificationStatus.Unknown); + } + + [Theory] + [InlineData("example.com", 22, "example.com")] + [InlineData("EXAMPLE.com", 22, "other.org,example.com")] + [InlineData("example.com", 2222, "[example.com]:2222")] + [InlineData("host1.example.com", 22, "*.example.com")] + public void Verify_MatchingEntry_ReturnsTrusted(string host, int port, string pattern) + { + WriteKnownHosts("# comment", string.Empty, Entry(pattern, Ed25519Key)); + + _store.Verify(new HostKeyInfo(host, port, Ed25519Key)).ShouldBe(HostKeyVerificationStatus.Trusted); + } + + [Fact] + public void Verify_HashedEntry_ReturnsTrusted() + { + var salt = RandomNumberGenerator.GetBytes(20); + var hash = HMACSHA1.HashData(salt, Encoding.UTF8.GetBytes("example.com")); + WriteKnownHosts(Entry($"|1|{Convert.ToBase64String(salt)}|{Convert.ToBase64String(hash)}", Ed25519Key)); + + _store.Verify(new HostKeyInfo("example.com", 22, Ed25519Key)).ShouldBe(HostKeyVerificationStatus.Trusted); + _store.Verify(new HostKeyInfo("example.org", 22, Ed25519Key)).ShouldBe(HostKeyVerificationStatus.Unknown); + } + + [Fact] + public void Verify_DefaultPortEntry_DoesNotMatchOtherPort() + { + WriteKnownHosts(Entry("example.com", Ed25519Key)); + + _store.Verify(new HostKeyInfo("example.com", 2222, Ed25519Key)).ShouldBe(HostKeyVerificationStatus.Unknown); + } + + [Fact] + public void Verify_NegatedPattern_ReturnsUnknown() + { + WriteKnownHosts(Entry("*.example.com,!evil.example.com", Ed25519Key)); + + _store.Verify(new HostKeyInfo("evil.example.com", 22, Ed25519Key)) + .ShouldBe(HostKeyVerificationStatus.Unknown); + } + + [Fact] + public void Verify_SameTypeDifferentKey_ReturnsMismatch() + { + WriteKnownHosts(Entry("example.com", Ed25519Key)); + + _store.Verify(new HostKeyInfo("example.com", 22, OtherEd25519Key)) + .ShouldBe(HostKeyVerificationStatus.Mismatch); + } + + [Fact] + public void Verify_OnlyOtherKeyTypeKnown_ReturnsUnknown() + { + WriteKnownHosts(Entry("example.com", EcdsaKey)); + + _store.Verify(new HostKeyInfo("example.com", 22, Ed25519Key)).ShouldBe(HostKeyVerificationStatus.Unknown); + } + + [Fact] + public void Verify_RevokedKey_ReturnsRevokedEvenIfTrusted() + { + WriteKnownHosts(Entry("example.com", Ed25519Key), Entry("*", Ed25519Key, "@revoked")); + + _store.Verify(new HostKeyInfo("example.com", 22, Ed25519Key)).ShouldBe(HostKeyVerificationStatus.Revoked); + } + + [Fact] + public void Verify_CertAuthorityEntry_IsIgnored() + { + WriteKnownHosts(Entry("example.com", Ed25519Key, "@cert-authority")); + + _store.Verify(new HostKeyInfo("example.com", 22, Ed25519Key)).ShouldBe(HostKeyVerificationStatus.Unknown); + } + + [Fact] + public void Add_AppendsEntryThatVerifiesAsTrusted() + { + File.WriteAllText(_path, Entry("other.org", EcdsaKey)); // no trailing newline + var hostKey = new HostKeyInfo("Example.com", 2222, Ed25519Key); + + _store.Add(hostKey); + + _store.Verify(hostKey).ShouldBe(HostKeyVerificationStatus.Trusted); + File.ReadAllLines(_path).ShouldBe([Entry("other.org", EcdsaKey), Entry("[example.com]:2222", Ed25519Key)]); + } + + [Fact] + public void Add_NewFile_IsCreatedOwnerOnly() + { + Assert.SkipWhen(OperatingSystem.IsWindows(), "Unix file modes only"); + + _store.Add(new HostKeyInfo("example.com", 22, Ed25519Key)); + +#pragma warning disable CA1416 + File.GetUnixFileMode(_path).ShouldBe(UnixFileMode.UserRead | UnixFileMode.UserWrite); +#pragma warning restore CA1416 + } + + [Fact] + public void HostKeyInfo_FingerprintMatchesOpenSshNotation() + { + var expected = "SHA256:" + Convert.ToBase64String(SHA256.HashData(Ed25519Key)).TrimEnd('='); + + new HostKeyInfo("example.com", 22, Ed25519Key).FingerprintSha256.ShouldBe(expected); + } +} diff --git a/OpenSSH_GUI/Extensions/DependencyInjectionExtensions.cs b/OpenSSH_GUI/Extensions/DependencyInjectionExtensions.cs index caebb19..b5723ef 100644 --- a/OpenSSH_GUI/Extensions/DependencyInjectionExtensions.cs +++ b/OpenSSH_GUI/Extensions/DependencyInjectionExtensions.cs @@ -15,6 +15,7 @@ using OpenSSH_GUI.Core.Services.Hosted; using OpenSSH_GUI.Dialogs.Interfaces; using OpenSSH_GUI.Dialogs.Services; +using OpenSSH_GUI.Services; using OpenSSH_GUI.ViewModels; using OpenSSH_GUI.Views; using Serilog.Core; @@ -34,6 +35,8 @@ internal IHostBuilder RegisterOpenSshGuiServices() services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(sp => sp.GetRequiredService()); diff --git a/OpenSSH_GUI/Resources/StringsAndTexts.Designer.cs b/OpenSSH_GUI/Resources/StringsAndTexts.Designer.cs index 42a90b8..f6e4648 100644 --- a/OpenSSH_GUI/Resources/StringsAndTexts.Designer.cs +++ b/OpenSSH_GUI/Resources/StringsAndTexts.Designer.cs @@ -806,5 +806,17 @@ public static string ApplicationSettingsLookupPaths { return ResourceManager.GetString("ApplicationSettingsLookupPaths", resourceCulture); } } + + public static string HostKeyUnknownTitle { + get { + return ResourceManager.GetString("HostKeyUnknownTitle", resourceCulture); + } + } + + public static string HostKeyUnknownText { + get { + return ResourceManager.GetString("HostKeyUnknownText", resourceCulture); + } + } } } diff --git a/OpenSSH_GUI/Resources/StringsAndTexts.de.resx b/OpenSSH_GUI/Resources/StringsAndTexts.de.resx index 239021b..6f2b024 100644 --- a/OpenSSH_GUI/Resources/StringsAndTexts.de.resx +++ b/OpenSSH_GUI/Resources/StringsAndTexts.de.resx @@ -393,4 +393,15 @@ Suchpfade + + Unbekannter Host-Schlüssel + + + Die Echtheit des Hosts '{0}' kann nicht bestätigt werden. + +{1}-Fingerprint: +{2} + +Fahren Sie nur fort, wenn Sie diesen Fingerprint überprüft haben. Diesem Host vertrauen und den Schlüssel in known_hosts aufnehmen? + \ No newline at end of file diff --git a/OpenSSH_GUI/Resources/StringsAndTexts.resx b/OpenSSH_GUI/Resources/StringsAndTexts.resx index 75f14d8..88384f0 100644 --- a/OpenSSH_GUI/Resources/StringsAndTexts.resx +++ b/OpenSSH_GUI/Resources/StringsAndTexts.resx @@ -406,4 +406,15 @@ Lookup Paths + + Unknown host key + + + The authenticity of host '{0}' can't be established. + +{1} key fingerprint: +{2} + +Only continue if you have verified this fingerprint. Trust this host and add the key to known_hosts? + \ No newline at end of file diff --git a/OpenSSH_GUI/Services/HostKeyTrustPrompt.cs b/OpenSSH_GUI/Services/HostKeyTrustPrompt.cs new file mode 100644 index 0000000..75ba31f --- /dev/null +++ b/OpenSSH_GUI/Services/HostKeyTrustPrompt.cs @@ -0,0 +1,28 @@ +using Avalonia.Threading; +using Material.Icons; +using OpenSSH_GUI.Core.Interfaces; +using OpenSSH_GUI.Core.Lib.HostKeys; +using OpenSSH_GUI.Dialogs.Enums; +using OpenSSH_GUI.Dialogs.Interfaces; +using OpenSSH_GUI.Resources; + +namespace OpenSSH_GUI.Services; + +/// +/// Shows the fingerprint of an unknown server host key and lets the user decide whether to trust it. +/// +public sealed class HostKeyTrustPrompt(IMessageBoxProvider messageBoxProvider) : IHostKeyTrustPrompt +{ + /// + public async Task ConfirmUnknownHostKeyAsync(HostKeyInfo hostKey) + { + var result = await Dispatcher.UIThread.InvokeAsync(() => messageBoxProvider.ShowMessageBoxAsync( + StringsAndTexts.HostKeyUnknownTitle, + string.Format( + StringsAndTexts.HostKeyUnknownText, hostKey.KnownHostsName, hostKey.KeyType, + hostKey.FingerprintSha256), + MessageBoxButtons.YesNo, + MaterialIconKind.ShieldAlertOutline)); + return result is MessageBoxResult.Yes; + } +}