Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions OpenSSH_GUI.Core/Interfaces/IHostKeyTrustPrompt.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
using OpenSSH_GUI.Core.Lib.HostKeys;

namespace OpenSSH_GUI.Core.Interfaces;

/// <summary>
/// Asks the user whether an unknown server host key should be trusted (trust on first use).
/// </summary>
public interface IHostKeyTrustPrompt
{
/// <returns><c>true</c> if the user confirmed the fingerprint and the key should be trusted.</returns>
Task<bool> ConfirmUnknownHostKeyAsync(HostKeyInfo hostKey);
}
19 changes: 19 additions & 0 deletions OpenSSH_GUI.Core/Interfaces/IKnownHostKeyStore.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
using OpenSSH_GUI.Core.Lib.HostKeys;

namespace OpenSSH_GUI.Core.Interfaces;

/// <summary>
/// Verifies server host keys against the user's <c>known_hosts</c> file and records newly trusted keys.
/// </summary>
public interface IKnownHostKeyStore
{
/// <summary>
/// Checks the presented host key against the <c>known_hosts</c> entries of the host.
/// </summary>
HostKeyVerificationStatus Verify(HostKeyInfo hostKey);

/// <summary>
/// Appends the host key to <c>known_hosts</c>.
/// </summary>
void Add(HostKeyInfo hostKey);
}
49 changes: 49 additions & 0 deletions OpenSSH_GUI.Core/Lib/HostKeys/HostKeyInfo.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
using System.Buffers.Binary;
using System.Security.Cryptography;
using System.Text;

namespace OpenSSH_GUI.Core.Lib.HostKeys;

/// <summary>
/// A server host key as presented during the SSH key exchange.
/// </summary>
/// <param name="Host">The host name the connection was made to.</param>
/// <param name="Port">The port the connection was made to.</param>
/// <param name="KeyBlob">The public key in SSH wire format.</param>
public sealed record HostKeyInfo(string Host, int Port, byte[] KeyBlob)
{
/// <summary>
/// The key type encoded in the key blob, e.g. <c>ssh-ed25519</c>.
/// </summary>
public string KeyType { get; } = ReadKeyType(KeyBlob) ?? string.Empty;

/// <summary>
/// The fingerprint in OpenSSH notation, e.g. <c>SHA256:...</c>.
/// </summary>
public string FingerprintSha256 =>
$"SHA256:{Convert.ToBase64String(SHA256.HashData(KeyBlob)).TrimEnd('=')}";

/// <summary>
/// The host name as written to and matched against <c>known_hosts</c>
/// (<c>[host]:port</c> for non-default ports).
/// </summary>
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}";
}

/// <summary>
/// Reads the leading key type string of an SSH wire format public key.
/// </summary>
public static string? ReadKeyType(ReadOnlySpan<byte> 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));
}
}
26 changes: 26 additions & 0 deletions OpenSSH_GUI.Core/Lib/HostKeys/HostKeyVerificationException.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
namespace OpenSSH_GUI.Core.Lib.HostKeys;

/// <summary>
/// Thrown when a connection is aborted because the server host key could not be verified.
/// </summary>
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."
};
}
19 changes: 19 additions & 0 deletions OpenSSH_GUI.Core/Lib/HostKeys/HostKeyVerificationStatus.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
namespace OpenSSH_GUI.Core.Lib.HostKeys;

/// <summary>
/// Result of checking a server host key against the local <c>known_hosts</c> file.
/// </summary>
public enum HostKeyVerificationStatus
{
/// <summary>No entry exists for the host and key type.</summary>
Unknown,

/// <summary>An entry for the host matches the presented key.</summary>
Trusted,

/// <summary>The host is known with a different key of the same type - possible man-in-the-middle attack.</summary>
Mismatch,

/// <summary>The presented key is marked as <c>@revoked</c>.</summary>
Revoked
}
24 changes: 22 additions & 2 deletions OpenSSH_GUI.Core/Lib/Misc/ServerConnection.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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<HostKeyInfo, bool>? hostKeyValidator = null)
{
ConnectionCredentials = credentials ?? ConnectionCredentials.Empty;
var connectionInfo = ConnectionCredentials.GetConnectionInfo();
Expand All @@ -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<HostKeyEventArgs> 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)
Expand Down Expand Up @@ -91,7 +102,16 @@ public void Dispose()
ClientConnection.Dispose();
}

public static ServerConnection WithCredentials(ConnectionCredentials credentials) => new(credentials);
/// <summary>
/// Creates a connection for the given credentials.
/// </summary>
/// <param name="credentials">The credentials used to authenticate.</param>
/// <param name="hostKeyValidator">
/// Decides whether the host key presented by the server is trusted. The connection is aborted
/// during the key exchange when it returns <c>false</c>.
/// </param>
public static ServerConnection WithCredentials(ConnectionCredentials credentials,
Func<HostKeyInfo, bool> hostKeyValidator) => new(credentials, hostKeyValidator);

public async ValueTask<bool> ConnectToServerAsync(CancellationToken token = default)
{
Expand Down
168 changes: 168 additions & 0 deletions OpenSSH_GUI.Core/Services/KnownHostKeyStore.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// <see cref="IKnownHostKeyStore" /> backed by the user's <c>known_hosts</c> file.
/// Supports plain and hashed (<c>|1|salt|hash</c>) host names, <c>[host]:port</c> entries,
/// wildcard and negated patterns as well as the <c>@revoked</c> marker.
/// <c>@cert-authority</c> entries are ignored.
/// </summary>
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<KnownHostKeyStore> _logger;

public KnownHostKeyStore(ILogger<KnownHostKeyStore> logger)
: this(logger, SshConfigFiles.Known_Hosts.GetPathOfFile()) { }

public KnownHostKeyStore(ILogger<KnownHostKeyStore> logger, string knownHostsPath)
{
_logger = logger;
_knownHostsPath = knownHostsPath;
}

/// <inheritdoc />
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;
}

/// <inheritdoc />
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|<base64 salt>|<base64 HMAC-SHA1(salt, host)>
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;
}
}
}
Loading
Loading