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;
+ }
+}